From 506c7ff01efbe83c7cfb235785818160dbab5844 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 2 Oct 2026 10:53:05 -0400 Subject: [PATCH 1/2] Apply EFSP validation rules and add actionable party error links --- efile_app/efile/api/filing_views.py | 17 +- efile_app/efile/forms.py | 63 +-- efile_app/efile/services/efsp_errors.py | 192 ++++++++ efile_app/efile/services/efsp_payload.py | 54 +++ efile_app/efile/services/efsp_validation.py | 224 +++++++++ efile_app/efile/services/postal_codes.py | 70 +++ efile_app/efile/static/js/api-utils.js | 5 +- .../efile/static/js/filing-error-actions.js | 20 + efile_app/efile/static/js/filing-payload.js | 4 +- efile_app/efile/static/js/party-validation.js | 77 ++++ efile_app/efile/static/js/payment.js | 5 +- efile_app/efile/static/js/review.js | 11 +- .../efile/partials/state_choices.html | 28 ++ .../efile/templates/efile/party_details.html | 61 ++- efile_app/efile/templates/efile/payment.html | 3 + efile_app/efile/templates/efile/register.html | 1 + efile_app/efile/templates/efile/review.html | 9 +- .../templates/efile/your_information.html | 58 ++- .../efile/templatetags/efsp_validation.py | 34 ++ efile_app/efile/tests/conftest.py | 6 + .../fixtures/efsp_validation_2026-10-02.json | 432 ++++++++++++++++++ efile_app/efile/tests/test_efsp_validation.py | 302 ++++++++++++ efile_app/efile/tests/test_people_flow.py | 6 +- efile_app/efile/views/party_details.py | 65 ++- efile_app/efile/views/session_api.py | 9 +- efile_app/efile/views/your_information.py | 30 +- efile_app/js-tests/api-utils.test.js | 7 +- efile_app/js-tests/review-payment.test.js | 1 + efile_app/tests/efsp-validation-browser.js | 65 +++ 29 files changed, 1704 insertions(+), 155 deletions(-) create mode 100644 efile_app/efile/services/efsp_validation.py create mode 100644 efile_app/efile/services/postal_codes.py create mode 100644 efile_app/efile/static/js/filing-error-actions.js create mode 100644 efile_app/efile/static/js/party-validation.js create mode 100644 efile_app/efile/templates/efile/partials/state_choices.html create mode 100644 efile_app/efile/templatetags/efsp_validation.py create mode 100644 efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json create mode 100644 efile_app/efile/tests/test_efsp_validation.py create mode 100644 efile_app/tests/efsp-validation-browser.js diff --git a/efile_app/efile/api/filing_views.py b/efile_app/efile/api/filing_views.py index 380e5c13..1c5be240 100644 --- a/efile_app/efile/api/filing_views.py +++ b/efile_app/efile/api/filing_views.py @@ -14,7 +14,7 @@ from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request from ..services.current_drafts import get_current_draft -from ..services.efsp_errors import describe_efsp_error +from ..services.efsp_errors import describe_efsp_error, efsp_error_problems, error_actions from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload from ..services.fee_quotes import fee_inputs_token, fee_quote_summary, quote_from_efsp_response, record_fee_quote from ..utils.case_data_utils import get_case_data @@ -161,6 +161,8 @@ def payment_fees(request): case_data = get_case_data(request, jurisdiction_id) court_id = case_data.get("court", "") + draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False) + # Must match what submit_final_filing sends, or fees are quoted # against a payload that differs from the one actually filed. try: @@ -168,7 +170,14 @@ def payment_fees(request): except PayloadValidationError as error: # Known-bad payload: answer with the specific reason rather than # letting the EFSP reply with a code-list error no filer can act on. - return JsonResponse({"success": False, "error": str(error)}, status=400) + return JsonResponse( + { + "success": False, + "error": str(error), + "error_actions": error_actions(draft, efile_data, error.problems), + }, + status=400, + ) url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/filingreview/courts/{court_id}/filing/fees" @@ -188,7 +197,6 @@ def payment_fees(request): # The draft this request prices, as the page that built it saw it. # Checked again when the EFSP answers: the request can take a # minute, and the filing can be edited in another tab meanwhile. - draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False) inputs_token = str(data.get("fee_inputs_token") or "") priced_current_draft = draft is not None and bool(inputs_token) and fee_inputs_token(draft) == inputs_token @@ -234,6 +242,9 @@ def payment_fees(request): { "success": False, "error": f"Could not get filing fees: {error_message}", + "error_actions": error_actions( + draft, efile_data, efsp_error_problems(response), message=error_message + ), "api_status_code": response.status_code, "api_response": response.text[:500] if response.text else "No response body", }, diff --git a/efile_app/efile/forms.py b/efile_app/efile/forms.py index d8a314eb..2b74cba5 100644 --- a/efile_app/efile/forms.py +++ b/efile_app/efile/forms.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from efile.services.efsp_validation import state_choices +from efile.services.postal_codes import USPS_STATES from efile.utils.account_ids import jurisdiction_account_username User = get_user_model() @@ -28,6 +30,13 @@ class EFileRegistrationForm(forms.Form): def __init__(self, *args, jurisdiction=None, **kwargs): super().__init__(*args, **kwargs) self.jurisdiction = jurisdiction + choices, source = state_choices(jurisdiction, "0") + self.fields["state"].choices = [("", "Select a state"), *choices] + if source == "efsp": + self.fields["state"].help_text = ( + "Only locations accepted by the filing service are listed. If your address is not listed, " + "contact the court for filing instructions. Do not choose a different state." + ) # Legal Name first_name = forms.CharField( @@ -54,59 +63,7 @@ def __init__(self, *args, jurisdiction=None, **kwargs): city = forms.CharField( max_length=100, widget=forms.TextInput(attrs={"class": "form-control", "id": "city", "required": "required"}) ) - STATE_CHOICES = [ - ("", "Select a state"), - ("AL", "Alabama"), - ("AK", "Alaska"), - ("AZ", "Arizona"), - ("AR", "Arkansas"), - ("CA", "California"), - ("CO", "Colorado"), - ("CT", "Connecticut"), - ("DE", "Delaware"), - ("FL", "Florida"), - ("GA", "Georgia"), - ("HI", "Hawaii"), - ("ID", "Idaho"), - ("IL", "Illinois"), - ("IN", "Indiana"), - ("IA", "Iowa"), - ("KS", "Kansas"), - ("KY", "Kentucky"), - ("LA", "Louisiana"), - ("ME", "Maine"), - ("MD", "Maryland"), - ("MA", "Massachusetts"), - ("MI", "Michigan"), - ("MN", "Minnesota"), - ("MS", "Mississippi"), - ("MO", "Missouri"), - ("MT", "Montana"), - ("NE", "Nebraska"), - ("NV", "Nevada"), - ("NH", "New Hampshire"), - ("NJ", "New Jersey"), - ("NM", "New Mexico"), - ("NY", "New York"), - ("NC", "North Carolina"), - ("ND", "North Dakota"), - ("OH", "Ohio"), - ("OK", "Oklahoma"), - ("OR", "Oregon"), - ("PA", "Pennsylvania"), - ("RI", "Rhode Island"), - ("SC", "South Carolina"), - ("SD", "South Dakota"), - ("TN", "Tennessee"), - ("TX", "Texas"), - ("UT", "Utah"), - ("VT", "Vermont"), - ("VA", "Virginia"), - ("WA", "Washington"), - ("WV", "West Virginia"), - ("WI", "Wisconsin"), - ("WY", "Wyoming"), - ] + STATE_CHOICES = [("", "Select a state"), *USPS_STATES.items()] state = forms.ChoiceField( choices=STATE_CHOICES, widget=forms.Select(attrs={"class": "form-select", "id": "state", "required": "required"}), diff --git a/efile_app/efile/services/efsp_errors.py b/efile_app/efile/services/efsp_errors.py index fe2c199d..1568a8f5 100644 --- a/efile_app/efile/services/efsp_errors.py +++ b/efile_app/efile/services/efsp_errors.py @@ -27,6 +27,11 @@ import json import re from collections.abc import Callable +from urllib.parse import urlencode + +from django.urls import reverse + +from efile.services.efsp_validation import FIELD_LABELS # Field names the EFSP uses, in the words the UI uses for them. _FIELD_LABELS = { @@ -53,6 +58,21 @@ # not just what went wrong. Patterns are deliberately specific substrings of # the proxy's own wording so an unrelated message never matches by accident. _KNOWN_MESSAGE_HINTS: list[tuple[re.Pattern, Callable[[re.Match], str]]] = [ + ( + re.compile(r"(?:doesn't|dosesn't|does not) support a state named", re.IGNORECASE), + lambda m: ( + "Check the party's state or location against the address dropdown. " + "If the address is not supported, contact the court for filing instructions. " + "Do not choose a different state." + ), + ), + ( + re.compile(r"PersonSurName is required or does not match regular expression", re.IGNORECASE), + lambda m: ( + "Check the last names in the party information. If a party is a company or other organization, " + "choose Organization and enter its organization name." + ), + ), ( re.compile(r"doesn't allow subsequent filing into non-indexed cases", re.IGNORECASE), lambda m: ( @@ -126,6 +146,8 @@ def describe_efsp_error(response) -> str: if not isinstance(body, dict): return f"the court's filing service returned status {response.status_code}: {str(body)[:_MAX_RAW_BODY]}" + body = _unwrap_error(body) + problems = [ *(_describe_var(var, missing=False) for var in _var_list(body, "wrong_vars")), *(_describe_var(var, missing=True) for var in _var_list(body, "required_vars")), @@ -134,6 +156,9 @@ def describe_efsp_error(response) -> str: if problems: return "the court could not accept this filing: " + "; ".join(problems) + if body.get("name"): + return _describe_var(body, missing=False) + # Some errors do arrive as a plain message. error = body.get("error") or body.get("message") or body.get("detail") if error: @@ -151,6 +176,14 @@ def describe_efsp_error(response) -> str: message = f"{error_type}: {description}" if error_type else str(description) return _with_hint(message) + # The proxy's collector includes general errors alongside wrong_vars. + errors = body.get("errors") + if isinstance(errors, list) and errors: + return "; ".join( + _with_hint(str(item.get("description") or item)) if isinstance(item, dict) else _with_hint(str(item)) + for item in errors + ) + return f"the court's filing service returned status {response.status_code}" @@ -165,6 +198,23 @@ def _describe_var(var, *, missing: bool) -> str: return "" current = str(var.get("currentVal") or "").strip() + party_match = _PARTY_FIELD.fullmatch(name) + if party_match and party_match[3] in _PARTY_FIELDS: + collection, index, path = party_match.groups() + field = _PARTY_FIELDS[path] + label = FIELD_LABELS.get(field, field.replace("_", " ")) + who = f"{'filing' if collection == 'users' else 'other'} party {int(index) + 1}" + description = str(var.get("description") or "") + length = re.search(r"can't exceed (\d+) characters", description) + regex_length = re.search(r"must match regex: \^\.\{0,(\d+)\}\$", description) + if not length: + length = regex_length + if length: + return f"{label} for {who} must be {length[1]} characters or fewer" + if missing or not current: + return f"{label.lower()} is required for {who}" + return f"{current!r} is not a {label.lower()} the court accepts for {who}" + match = _BUNDLE_FIELD.match(name) if match: index, name = match.groups() @@ -184,3 +234,145 @@ def _describe_var(var, *, missing: bool) -> str: if missing or not current: return f"no {label} was given{where}" return f"{current!r} is not a {label} this court accepts{where}" + + +_PARTY_FIELD = re.compile(r"^(users|other_parties)\[(\d+)\]\.(.+)$") +_PARTY_FIELDS = { + "name.first": "first_name", + "name.middle": "middle_name", + "name.last": "last_name", + "name.suffix": "suffix", + # NameDocassembleDeserializer adds name.suffix while already inside name. + "name.name.suffix": "suffix", + "email": "email", + "phone_number": "phone", + "mobile_number": "phone", + "address.address": "address_line_1", + "address.unit": "address_line_2", + "address.city": "city", + "address.state": "state", + "address.zip": "zip_code", + "party_type": "party_type", +} +_STATE_MESSAGE = re.compile( + r"(?:Opposing party|Filing party).*?(?:doesn't|dosesn't|does not) support a state named\s+(.+?)(?:\.$|$)", + re.IGNORECASE, +) + + +def efsp_error_problems(response): + """Retain field paths instead of making the browser parse rendered prose.""" + try: + body = response.json() + except (ValueError, TypeError): + return [] + if not isinstance(body, dict): + return [] + body = _unwrap_error(body) + problems = [] + for key in ("wrong_vars", "required_vars"): + for var in _var_list(body, key): + problems.append( + {"name": str(var.get("name") or ""), "message": _describe_var(var, missing=key == "required_vars")} + ) + if body.get("name"): + problems.append({"name": str(body["name"]), "message": _describe_var(body, missing=False)}) + return problems + + +def _unwrap_error(body): + """InfoCollector wraps a single FilingError as {error: {...}}.""" + nested = body.get("error") + return nested if isinstance(nested, dict) else body + + +def _matching_party(wire, parties): + """A link is safe only when the rejected payload identifies exactly one row. + + Array order is not draft order: users can be someone the filer acts for, + and other_parties excludes those users. Duplicate names remain ambiguous. + """ + name = wire.get("name") + if not isinstance(name, dict) or not name.get("first"): + return None + organization = str(wire.get("person_type") or "").lower() in {"business", "organization"} + matches = [] + for party in parties: + if organization: + same = party.organization_name == name.get("first") + else: + same = ( + party.first_name == name.get("first") + and party.last_name == (name.get("last") or "") + and party.middle_name == (name.get("middle") or "") + and not party.organization_name + ) + if same and party.party_type == (wire.get("party_type") or ""): + matches.append(party) + return matches[0] if len(matches) == 1 else None + + +def error_actions(draft, payload, problems, *, message=""): + """Build local, draft-bound edit links only for confidently located fields.""" + if draft is None or not isinstance(payload, dict): + return [] + parties = list(draft.parties.all()) + candidates = list(problems) + # Older proxy wording contains the rejected state, but no field path. + # Locate it only when exactly one outgoing party has that value. + match = _STATE_MESSAGE.search(message) + if match: + state = match[1].strip(" '\"") + locations = [] + for collection in ("users", "other_parties"): + rows = payload.get(collection) + if not isinstance(rows, list): + continue + for index, wire in enumerate(rows): + if ( + isinstance(wire, dict) + and isinstance(wire.get("address"), dict) + and wire["address"].get("state") == state + ): + locations.append(f"{collection}[{index}].address.state") + if len(locations) == 1: + candidates.append({"name": locations[0], "message": "Check this party's state or location."}) + actions = [] + seen = set() + for problem in candidates: + match = _PARTY_FIELD.fullmatch(str(problem.get("name") or "")) + if not match or match[3] not in _PARTY_FIELDS: + continue + collection, index, path = match.groups() + rows = payload.get(collection) + if not isinstance(rows, list) or int(index) >= len(rows) or not isinstance(rows[int(index)], dict): + continue + wire = rows[int(index)] + party = _matching_party(wire, parties) + if party is None or party.role not in {"filer", "other"}: + continue + field = _PARTY_FIELDS[path] + # The filer's court role is collected on a separate screen, not on + # Your information. Do not offer a field link that cannot focus it. + if party.role == "filer" and field == "party_type": + continue + if field == "first_name" and party.organization_name: + field = "organization_name" + if (party.pk, field) in seen: + continue + seen.add((party.pk, field)) + view = "your_information" if party.role == "filer" else "party_details" + params = {"draft": draft.pk, "return_to": "review", "focus": field} + if party.role != "filer": + params["party"] = party.pk + url = reverse(view, kwargs={"jurisdiction": draft.jurisdiction}) + "?" + urlencode(params) + label = FIELD_LABELS.get(field, field.replace("_", " ")) + display_name = party.organization_name or " ".join(filter(None, [party.first_name, party.last_name])) + actions.append( + { + "url": url, + "label": f"Edit {label.lower()} for {display_name}", + "message": problem.get("message") or f"Check {label.lower()}.", + } + ) + return actions diff --git a/efile_app/efile/services/efsp_payload.py b/efile_app/efile/services/efsp_payload.py index 8b86c181..ae8f300a 100644 --- a/efile_app/efile/services/efsp_payload.py +++ b/efile_app/efile/services/efsp_payload.py @@ -13,6 +13,8 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from efile.services.efsp_validation import party_validation, validate_party + logger = logging.getLogger(__name__) # Labels the UI has historically stored in place of a real court filing-component @@ -35,6 +37,10 @@ class PayloadValidationError(Exception): the message can be specific. Views turn this into a 400 carrying the message. """ + def __init__(self, message, *, problems=None): + super().__init__(message) + self.problems = problems or [] + class _EfspLookups: """Cached, time-budgeted GETs against the EFSP code lists for one payload. @@ -126,6 +132,7 @@ def prepare_efile_payload(efile_data, jurisdiction_id, court_id): """ lookups = _EfspLookups() _clean_case_identifiers(efile_data) + validate_party_formats(efile_data, jurisdiction_id, court_id) validate_lower_court(efile_data, jurisdiction_id, court_id, lookups=lookups) _drop_empty_cross_references(efile_data) substitute_test_document_urls(efile_data) @@ -136,6 +143,53 @@ def prepare_efile_payload(efile_data, jurisdiction_id, court_id): return efile_data +def validate_party_formats(payload, jurisdiction, court): + """Catch invalid extracted/saved values even if a people form was bypassed.""" + metadata_by_country = {} + problems = [] + for collection in ("users", "other_parties"): + parties = payload.get(collection) + if not isinstance(parties, list): + continue + for index, party in enumerate(parties): + if not isinstance(party, dict): + continue + address = party.get("address") or {} + name = party.get("name") or {} + if not isinstance(address, dict) or not isinstance(name, dict): + continue + country = str(address.get("country") or "US").upper() + if country not in metadata_by_country: + metadata_by_country[country] = party_validation(jurisdiction, court, country) + metadata = metadata_by_country[country] + organization = str(party.get("person_type") or "").lower() in {"business", "organization"} + values = { + "first_name": name.get("first"), + "middle_name": name.get("middle"), + "last_name": name.get("last"), + "organization_name": name.get("first"), + "email": party.get("email"), + "phone": party.get("phone_number") or party.get("mobile_number"), + "state": address.get("state"), + } + errors = validate_party(values, metadata, organization=organization, address_started=bool(address)) + paths = { + "first_name": "name.first", + "middle_name": "name.middle", + "last_name": "name.last", + "organization_name": "name.first", + "phone": "phone_number", + "email": "email", + "state": "address.state", + } + for field, message in errors.items(): + problems.append({"name": f"{collection}[{index}].{paths[field]}", "message": message}) + if problems: + raise PayloadValidationError( + "Check the party information: " + "; ".join(p["message"] for p in problems), problems=problems + ) + + def _clean_case_identifiers(efile_data): """New cases have neither a previous case ID nor a court-assigned docket number.""" if not str(efile_data.get("previous_case_id") or "").strip() or efile_data.get("user_started_case") is True: diff --git a/efile_app/efile/services/efsp_validation.py b/efile_app/efile/services/efsp_validation.py new file mode 100644 index 00000000..45c97d9d --- /dev/null +++ b/efile_app/efile/services/efsp_validation.py @@ -0,0 +1,224 @@ +"""Court code lists and text constraints shared by forms and outgoing filings. + +The datafield endpoint returns Java regexes. Only portable expressions are +evaluated locally; the EFSP remains the authority for unsupported expressions. +Match by search, as the proxy's DataFieldRow.matchRegex uses Matcher.find(). +""" + +import logging +import re +import time +from urllib.parse import quote + +import requests +from django.conf import settings +from django.core.cache import cache + +from efile.services.postal_codes import USPS_STATES + +logger = logging.getLogger(__name__) +FIELD_CODES = { + "first_name": "PartyFirstName", + "middle_name": "PartyMiddleName", + "last_name": "PartyLastName", + "organization_name": "PartyBusinessName", + "email": "PartyEmail", + "phone": "PartyPhone", +} +FIELD_LABELS = { + "first_name": "First name", + "middle_name": "Middle name", + "last_name": "Last name", + "organization_name": "Organization name", + "email": "Email", + "phone": "Phone", + "state": "State", + "zip_code": "ZIP code", +} +_LENGTH = re.compile(r"\^\.\{(\d+),(\d+)\}\$") + + +class RuleLookups: + """Bound lookup latency for a page, with fresh and last-known-good caches.""" + + def __init__(self): + self.deadline = time.monotonic() + 6 + + def get(self, jurisdiction, court, suffix, expected): + if not jurisdiction or not court: + return None + url = ( + f"{settings.EFSP_URL.rstrip('/')}/jurisdictions/{quote(jurisdiction, safe='')}" + f"/codes/courts/{quote(court, safe='')}/{suffix}" + ) + key = f"efsp-rule:{url}" + cached = cache.get(key) + if cached is not None: + return cached + if cache.get(f"{key}:unavailable"): + return cache.get(f"{key}:last-good") + remaining = self.deadline - time.monotonic() + if remaining > 0: + try: + response = requests.get(url, timeout=min(2, remaining)) # nosec B113 + if response.status_code == 200: + value = response.json() + valid = isinstance(value, expected) + if expected is list and valid: + valid = all(isinstance(code, str) and code.strip() for code in value) + if expected is dict and valid: + valid = value.get("code") in {"", suffix.rsplit("/", 1)[-1]} + if valid: + cache.set(key, value, 3600) + cache.set(f"{key}:last-good", value, 7 * 86400) + return value + except (requests.RequestException, ValueError): + logger.debug("Could not load EFSP validation metadata for %s", suffix) + # Short negative cache prevents one unavailable service causing a series + # of slow page loads. Empty lists are real results, not outages. + cached = cache.get(f"{key}:last-good") + if cached is not None: + cache.set(key, cached, 60) + else: + cache.set(f"{key}:unavailable", True, 60) + return cached + + +def state_choices(jurisdiction, court, country="US", *, lookups=None): + lookups = lookups or RuleLookups() + codes = lookups.get(jurisdiction, court, f"countries/{quote(country, safe='')}/states", list) + if isinstance(codes, list) and all(isinstance(code, str) and code.strip() for code in codes): + labels = USPS_STATES if country == "US" else {} + return sorted({(code, labels.get(code, code)) for code in codes}, key=lambda item: item[1]), "efsp" + # USPS codes are only meaningful for US addresses. Keep foreign data intact + # rather than suggesting US states as foreign provinces. + return list(USPS_STATES.items()) if country == "US" else [], "usps" if country == "US" else "unavailable" + + +def portable_regex(pattern): + """Return a shared Python/JS expression, or None for Java-only constructs.""" + if not isinstance(pattern, str) or not pattern or len(pattern) > 1000: + return None + # Java flags, atomic groups, class intersections, possessive quantifiers, + # Unicode categories, quoting and Java anchor/escape semantics differ. + if re.search(r"(? body?.error) + const serverBody = await response.clone().json() .catch(() => null); + const serverMessage = serverBody?.error; const error = new Error(serverMessage || `HTTP error! status: ${response.status}`); error.status = response.status; error.serverMessage = serverMessage || null; + error.errorActions = serverBody?.error_actions || []; throw error; } diff --git a/efile_app/efile/static/js/filing-error-actions.js b/efile_app/efile/static/js/filing-error-actions.js new file mode 100644 index 00000000..80dfdfb1 --- /dev/null +++ b/efile_app/efile/static/js/filing-error-actions.js @@ -0,0 +1,20 @@ +// The API supplies local edit links. Keep upstream/user text out of HTML. +window.FilingErrorActions = { + render(id, actions = []) { + const list = document.getElementById(id); + if (!list) return; + list.replaceChildren(); + for (const action of Array.isArray(actions) ? actions : []) { + if (typeof action.url !== "string" || !action.url.startsWith("/jurisdiction/")) continue; + const item = document.createElement("li"); + const message = document.createElement("span"); + message.textContent = `${action.message || ""} `; + const link = document.createElement("a"); + link.href = action.url; + link.textContent = action.label || gettext("Edit this field"); + item.append(message, link); + list.appendChild(item); + } + list.hidden = list.children.length === 0; + } +}; \ No newline at end of file diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js index aef24a36..e3b2b0fa 100644 --- a/efile_app/efile/static/js/filing-payload.js +++ b/efile_app/efile/static/js/filing-payload.js @@ -409,7 +409,7 @@ const FilingPayload = { window.location.href = result.redirect_url || `/jurisdiction/${jurisdiction}/filing-confirmation/`; }, 2000); } else { - Messages.showError(result?.error || "An error occurred during submission."); + Messages.showError(result?.error || "An error occurred during submission.", result?.error_actions); this.setSubmissionState(false); } }, @@ -449,7 +449,7 @@ const FilingPayload = { infoElem.appendChild(receipt); document.getElementById("paymentSection").removeAttribute("hidden"); } else { - Messages.showError(result?.error || "An error occurred when calculating fees."); + Messages.showError(result?.error || "An error occurred when calculating fees.", result?.error_actions); } this.setFeesState(false); } diff --git a/efile_app/efile/static/js/party-validation.js b/efile_app/efile/static/js/party-validation.js new file mode 100644 index 00000000..4df5bced --- /dev/null +++ b/efile_app/efile/static/js/party-validation.js @@ -0,0 +1,77 @@ +// Rules originate in court metadata. Server validation also runs without JS. +(() => { + const data = document.getElementById("party-validation-rules"); + if (!data) return; + const form = document.getElementById("your-information-form") || document.getElementById("party-details-form"); + const rules = JSON.parse(data.textContent); + const normalizePhone = (value) => { + let normalized = value.replace(/[-()]/g, "").trim(); + if (normalized.includes("+")) { + if (normalized.startsWith("+1") || normalized.startsWith("+0")) { + normalized = normalized.replaceAll("+1", "+1 ").replaceAll("+0", "+0 "); + } + return normalized; + } + return normalized.replaceAll(" ", ""); + }; + const validate = (field, rule) => { + const input = form.elements.namedItem(field); + if (!input) return; + if (input.disabled || input.closest("[hidden]")) { + input.setCustomValidity(""); + return; + } + let invalid = false; + if (input.value && rule.regex) { + try { + const regex = new RegExp(rule.regex, "u"); + invalid = !regex.test(input.value) && !(field === "phone" && regex.test(normalizePhone(input.value))); + } catch { + /* Java expressions unsupported by this browser stay server validated. */ + } + } + const message = invalid ? rule.message : ""; + input.setCustomValidity(message); + input.setAttribute("aria-invalid", String(invalid)); + const feedback = document.getElementById(`error_${field}`); + if (feedback) { + feedback.textContent = message; + feedback.hidden = !message; + } + }; + Object.entries(rules).forEach(([field, rule]) => { + const input = form.elements.namedItem(field); + if (!input) return; + if (rule.required && ["email", "phone"].includes(field)) input.required = true; + input.addEventListener("input", () => validate(field, rule)); + input.addEventListener("blur", () => validate(field, rule)); + // Pre-filled/extracted values deserve the same validation as typing. + validate(field, rule); + }); + form.addEventListener("submit", (event) => { + Object.entries(rules).forEach(([field, rule]) => validate(field, rule)); + if (!form.checkValidity()) { + event.preventDefault(); + form.reportValidity(); + } + }); + // Clear validity on hidden name fields when switching to an organization. + form.addEventListener("change", () => { + Object.entries(rules).forEach(([field, rule]) => validate(field, rule)); + }); + document.addEventListener("DOMContentLoaded", () => { + const field = new URLSearchParams(window.location.search).get("focus"); + // Only addressable form fields; never interpret a URL as a selector. + const id = field === "suffix" ? "suffix" : `id_${field}`; + const input = field ? document.getElementById(id) : null; + if (input && form.contains(input)) { + const address = input.closest("#party-address-fields"); + if (address?.hidden) { + const toggle = document.getElementById("add-party-address"); + toggle.checked = true; + toggle.dispatchEvent(new Event("change")); + } + input.focus(); + } + }); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/payment.js b/efile_app/efile/static/js/payment.js index 0fd7169a..7ae5b710 100644 --- a/efile_app/efile/static/js/payment.js +++ b/efile_app/efile/static/js/payment.js @@ -23,8 +23,9 @@ const paymentMessages = { document.getElementById("errorMessage").hidden = true; document.getElementById("successMessage").hidden = true; }, - showError(message) { + showError(message, actions = []) { document.getElementById("errorText").textContent = message; + window.FilingErrorActions?.render("filing-error-actions", actions); const box = document.getElementById("errorMessage"); box.hidden = false; box.scrollIntoView({ @@ -271,7 +272,7 @@ const PaymentPage = { } catch (error) { if (currentRequestId !== this.quoteRequestId) return; this.feeQuoteReady = false; - paymentMessages.showError(error?.serverMessage || gettext("We could not calculate fees. Please try again.")); + paymentMessages.showError(error?.serverMessage || gettext("We could not calculate fees. Please try again."), error?.errorActions); this.setFeesState(false); } }, diff --git a/efile_app/efile/static/js/review.js b/efile_app/efile/static/js/review.js index 82bcc0f7..64278123 100644 --- a/efile_app/efile/static/js/review.js +++ b/efile_app/efile/static/js/review.js @@ -5,8 +5,9 @@ const Messages = { document.getElementById("errorMessage").hidden = true; document.getElementById("successMessage").hidden = true; }, - showError(message) { + showError(message, actions = []) { document.getElementById("errorText").textContent = message; + window.FilingErrorActions?.render("filing-error-actions", actions); const box = document.getElementById("errorMessage"); box.hidden = false; box.scrollIntoView({ @@ -95,15 +96,17 @@ const FilingHandler = { document.getElementById("fee-quote-total").hidden = false; document.getElementById("fee-quote-pending").hidden = true; document.getElementById("fee-quote-error").hidden = true; + window.FilingErrorActions?.render("fee-error-actions", []); }, - showFeeError(message) { + showFeeError(message, actions = []) { this.setFreeFilingHelp(false); document.getElementById("submit-button-label").textContent = gettext("Submit filing"); document.getElementById("fee-quote-breakdown").replaceChildren(); document.getElementById("fee-quote-pending").hidden = true; document.getElementById("fee-quote-total").hidden = true; document.getElementById("fee-quote-error-text").textContent = message; + window.FilingErrorActions?.render("fee-error-actions", actions); document.getElementById("fee-quote-error").hidden = false; }, @@ -136,7 +139,7 @@ const FilingHandler = { this.showFeeQuote(quote); } catch (error) { this.feeQuoteState = "missing"; - this.showFeeError(`${error?.serverMessage || error?.message || gettext("We could not calculate the court's fees.")} ${gettext("You cannot submit until the fees are known.")}`); + this.showFeeError(`${error?.serverMessage || error?.message || gettext("We could not calculate the court's fees.")} ${gettext("You cannot submit until the fees are known.")}`, error?.errorActions); } this.setSubmissionState(false); }, @@ -164,7 +167,7 @@ const FilingHandler = { // Something changed in another tab since this page loaded. await this.refreshFeeQuote(); } - Messages.showError(error?.serverMessage || gettext("We could not submit the filing. Please try again.")); + Messages.showError(error?.serverMessage || gettext("We could not submit the filing. Please try again."), error?.errorActions); this.setSubmissionState(false); } } diff --git a/efile_app/efile/templates/efile/partials/state_choices.html b/efile_app/efile/templates/efile/partials/state_choices.html new file mode 100644 index 00000000..0b77852d --- /dev/null +++ b/efile_app/efile/templates/efile/partials/state_choices.html @@ -0,0 +1,28 @@ +{% load i18n %} +{% if state_source == "unavailable" %} + +{% else %} + +{% endif %} + + {% if state_source == "efsp" %} + {% translate "Only locations accepted by the filing service are listed. If your address is not listed, contact the court for filing instructions. Do not choose a different state." %} + {% elif state_source == "usps" %} + {% translate "The filing service's location list is unavailable. USPS locations, including territories and military addresses, are shown. The filing service will check whether it accepts your address." %} + {% else %} + {% translate "The filing service's list of states or provinces is unavailable. Enter your address as written; the filing service will check whether it accepts it." %} + {% endif %} + diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index 316bfd80..8391b269 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -1,6 +1,7 @@ {% extends "efile/workflow_base.html" %} {% load static %} {% load i18n %} +{% load efsp_validation %} {% block title %} {% translate "Party details" %} {% endblock title %} @@ -28,6 +29,7 @@

@@ -97,29 +99,36 @@

{% translate "Name" %}

+ {% if party_kind != "organization" %}required{% endif %} + {% validation_attrs "first_name" %}> + {% field_feedback "first_name" %} @@ -130,7 +139,9 @@

{% translate "Organization" %}

+ {% if party_kind == "organization" %}required{% endif %} + {% validation_attrs "organization_name" %}> + {% field_feedback "organization_name" %}
@@ -172,41 +183,49 @@

+ {% if address_required %}required{% endif %} + {% validation_attrs "address_line_1" %}> + {% field_feedback "address_line_1" %}

- {% translate "Contact information" %} {% translate "Optional" %} + {% translate "Contact information" %} + {% if not validation_rules.email.required and not validation_rules.phone.required %} + {% translate "Optional" %} + {% endif %}

@@ -273,6 +296,8 @@

{% include "efile/components/claim_party_dialog.html" %} {% endblock workflow_content %} {% block extra_js %} + {{ validation_rules|json_script:"party-validation-rules" }} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/payment.html b/efile_app/efile/templates/efile/payment.html index e2c8bdab..b40e0c01 100644 --- a/efile_app/efile/templates/efile/payment.html +++ b/efile_app/efile/templates/efile/payment.html @@ -152,6 +152,8 @@

{% translate "Confirmed fees" %}

{{ form.state.label_tag }} {{ form.state }} + {% if form.state.help_text %}
{{ form.state.help_text }}
{% endif %} {% if form.state.errors %}
{{ form.state.errors }}
{% endif %}
diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index eafe3f2b..e261125e 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -253,10 +253,12 @@

{% translate "Payment" %}

{% translate "We are calculating the court's fees for this filing." %} {% endif %}

- {% endif %}
@@ -290,6 +292,8 @@

{% translate "Payment" %}

{% endif %}
{% translate "Before you submit your filing" {{ fee_inputs_token|json_script:"fee-inputs-token" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/your_information.html b/efile_app/efile/templates/efile/your_information.html index 91afa884..4f7d7331 100644 --- a/efile_app/efile/templates/efile/your_information.html +++ b/efile_app/efile/templates/efile/your_information.html @@ -1,6 +1,7 @@ {% extends "efile/workflow_base.html" %} {% load static %} {% load i18n %} +{% load efsp_validation %} {% load ui_text %} {% block title %} {% translate "Your information" %} @@ -25,14 +26,18 @@

{% translate "Name" %}

name="first_name" value="{{ filer.first_name }}" autocomplete="given-name" - required /> + required + {% validation_attrs "first_name" %}> + {% field_feedback "first_name" %}
@@ -62,14 +70,18 @@

{% translate "Mailing address" %}

name="address_line_1" value="{{ filer.address_line_1 }}" autocomplete="address-line1" - required /> + required + {% validation_attrs "address_line_1" %}> + {% field_feedback "address_line_1" %} @@ -108,15 +120,23 @@

{% translate "Contact information" %}

name="email" value="{{ filer.email }}" autocomplete="email" - required /> + required + {% validation_attrs "email" %}> + {% field_feedback "email" %} @@ -139,5 +159,7 @@

{% translate "Contact information" %}

{% endblock workflow_content %} {% block extra_js %} + {{ validation_rules|json_script:"party-validation-rules" }} + {% endblock extra_js %} diff --git a/efile_app/efile/templatetags/efsp_validation.py b/efile_app/efile/templatetags/efsp_validation.py new file mode 100644 index 00000000..062ce67f --- /dev/null +++ b/efile_app/efile/templatetags/efsp_validation.py @@ -0,0 +1,34 @@ +"""Accessible field feedback for the people forms.""" + +from django import template +from django.utils.html import format_html, format_html_join + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def validation_attrs(context, field): + attrs = {"id": "suffix" if field == "suffix" else f"id_{field}", "aria-describedby": f"help_{field} error_{field}"} + if context.get("field_errors", {}).get(field): + attrs["aria-invalid"] = "true" + rule = context.get("validation_rules", {}).get(field, {}) + if field in {"email", "phone"} and rule.get("required"): + attrs["required"] = "required" + return format_html_join(" ", '{}="{}"', attrs.items()) + + +@register.simple_tag(takes_context=True) +def field_feedback(context, field): + rule = context.get("validation_rules", {}).get(field, {}) + help_text = rule.get("help", "") + if rule.get("max_length") is not None: + help_text = f"{help_text} {rule['max_length']} characters or fewer.".strip() + error = context.get("field_errors", {}).get(field, "") + return format_html( + '{}{}', + field, + help_text, + field, + "" if error else "hidden", + error, + ) diff --git a/efile_app/efile/tests/conftest.py b/efile_app/efile/tests/conftest.py index 43f03ba2..9824b5db 100644 --- a/efile_app/efile/tests/conftest.py +++ b/efile_app/efile/tests/conftest.py @@ -35,3 +35,9 @@ def court_requirements(monkeypatch): def appellate_code_lists(monkeypatch): """Appellate tests opt into specific catalogs; other tests never call EFSP.""" monkeypatch.setattr("efile.services.appeals.code_list", lambda jurisdiction, path, lookups=None: None) + + +@pytest.fixture(autouse=True) +def efsp_validation_metadata(monkeypatch): + """Validation lookup tests opt into metadata; other tests use USPS fallback.""" + monkeypatch.setattr("efile.services.efsp_validation.RuleLookups.get", lambda *args: None) diff --git a/efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json b/efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json new file mode 100644 index 00000000..cb535097 --- /dev/null +++ b/efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json @@ -0,0 +1,432 @@ +{ + "base_url": "https://efile-test.suffolklitlab.org/jurisdictions/", + "fetched_on": "2026-10-02", + "responses": [ + { + "path": "illinois/codes/courts/cook:cvd1/countries/US/states", + "body": [ + "TX", + "CO", + "AL", + "AK", + "AZ", + "AR", + "CA", + "CT", + "DE", + "DC", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY", + "PR", + "GU", + "VI" + ] + }, + { + "path": "illinois/codes/courts/cook:cvd1/datafields/PartyPhone", + "body": { + "code": "PartyPhone", + "name": "Party edit window: Phone Number", + "isvisible": true, + "isrequired": false, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": "^.{0,20}$", + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0" + } + }, + { + "path": "illinois/codes/courts/cook:cvd1/datafields/PartyEmail", + "body": { + "code": "PartyEmail", + "name": "Party edit window: Email", + "isvisible": true, + "isrequired": false, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0" + } + }, + { + "path": "illinois/codes/courts/cook:cvd1/datafields/PartyFirstName", + "body": { + "code": "PartyFirstName", + "name": "Party edit window: First Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0" + } + }, + { + "path": "illinois/codes/courts/cook:cvd1/datafields/PartyLastName", + "body": { + "code": "PartyLastName", + "name": "Party edit window: Last Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0" + } + }, + { + "path": "illinois/codes/courts/cook:cvd1/datafields/PartyBusinessName", + "body": { + "code": "PartyBusinessName", + "name": "Party edit window: Business Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0" + } + }, + { + "path": "massachusetts/codes/courts/0705:LA/countries/US/states", + "body": [ + "TX", + "CO", + "AL", + "AK", + "AZ", + "AR", + "CA", + "CT", + "DE", + "DC", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY" + ] + }, + { + "path": "massachusetts/codes/courts/0705:LA/datafields/PartyPhone", + "body": { + "code": "PartyPhone", + "name": "Party edit window: Phone Number", + "isvisible": true, + "isrequired": false, + "helptext": "", + "ghosttext": "Ex: 1234567890", + "contextualhelpdata": "", + "validationmessage": "Do not use hyphens or other characters--just numbers", + "regularexpression": "^(\\+0?1\\s)?\\(?\\d{3}\\)?\\d{3}\\d{4}$", + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0705:LA" + } + }, + { + "path": "massachusetts/codes/courts/0705:LA/datafields/PartyEmail", + "body": { + "code": "PartyEmail", + "name": "PartyEmail", + "isvisible": true, + "isrequired": false, + "helptext": "", + "ghosttext": "Example: someone@domain.com", + "contextualhelpdata": "", + "validationmessage": "Please enter a valid email address: Example: someone@domain.com", + "regularexpression": "^(.+)@(.+)\\.(.+)", + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0705:LA" + } + }, + { + "path": "massachusetts/codes/courts/0705:LA/datafields/PartyFirstName", + "body": { + "code": "PartyFirstName", + "name": "Party edit window: First Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "You must enter a First Name for your party.", + "regularexpression": "^.{0,50}$", + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0705:LA" + } + }, + { + "path": "massachusetts/codes/courts/0705:LA/datafields/PartyLastName", + "body": { + "code": "PartyLastName", + "name": "Party edit window: Last Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "You must enter a last name for your party.", + "regularexpression": "^.{0,50}$", + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0705:LA" + } + }, + { + "path": "massachusetts/codes/courts/0705:LA/datafields/PartyBusinessName", + "body": { + "code": "PartyBusinessName", + "name": "Party edit window: Business Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": "^.{0,254}$", + "defaultvalueexpression": "", + "isreadonly": false, + "location": "0705:LA" + } + }, + { + "path": "vermont/codes/courts/6000/countries/US/states", + "body": [ + "TX", + "CO", + "AL", + "AK", + "AZ", + "AR", + "CA", + "CT", + "DE", + "DC", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY" + ] + }, + { + "path": "vermont/codes/courts/6000/datafields/PartyPhone", + "body": { + "code": "PartyPhone", + "name": "Party edit window: Phone Number", + "isvisible": true, + "isrequired": false, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "6000" + } + }, + { + "path": "vermont/codes/courts/6000/datafields/PartyEmail", + "body": { + "code": "", + "name": "PartyEmail", + "isvisible": false, + "isrequired": false, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "" + } + }, + { + "path": "vermont/codes/courts/6000/datafields/PartyFirstName", + "body": { + "code": "PartyFirstName", + "name": "Party edit window: First Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "6000" + } + }, + { + "path": "vermont/codes/courts/6000/datafields/PartyLastName", + "body": { + "code": "PartyLastName", + "name": "Party edit window: Last Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "6000" + } + }, + { + "path": "vermont/codes/courts/6000/datafields/PartyBusinessName", + "body": { + "code": "PartyBusinessName", + "name": "Party edit window: Business Name", + "isvisible": true, + "isrequired": true, + "helptext": "", + "ghosttext": "", + "contextualhelpdata": "", + "validationmessage": "", + "regularexpression": null, + "defaultvalueexpression": "", + "isreadonly": false, + "location": "6000" + } + } + ] +} diff --git a/efile_app/efile/tests/test_efsp_validation.py b/efile_app/efile/tests/test_efsp_validation.py new file mode 100644 index 00000000..04fe7890 --- /dev/null +++ b/efile_app/efile/tests/test_efsp_validation.py @@ -0,0 +1,302 @@ +"""Use actual EFSP data to exercise prevention, outages and precise edit links.""" + +import json +from pathlib import Path +from unittest.mock import Mock, patch +from urllib.parse import parse_qs, urlsplit + +import pytest +import requests +from django.core.cache import cache +from django.forms import ChoiceField +from django.urls import reverse + +from efile.models import FilingDraft, FilingParty +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.efsp_errors import describe_efsp_error, efsp_error_problems, error_actions +from efile.services.efsp_payload import PayloadValidationError, validate_party_formats +from efile.services.efsp_validation import party_validation, portable_regex, state_choices, validate_party +from efile.services.postal_codes import USPS_STATES + +SAMPLES = json.loads((Path(__file__).parent / "fixtures/efsp_validation_2026-10-02.json").read_text()) +RESPONSES = {row["path"]: row["body"] for row in SAMPLES["responses"]} + + +@pytest.fixture(autouse=True) +def efsp_validation_metadata(monkeypatch, settings): + """Override the suite's fallback fixture with recorded live responses.""" + settings.EFSP_URL = "https://efile-test.suffolklitlab.org" + cache.clear() + + def get(url, **kwargs): + path = url.split("/jurisdictions/", 1)[-1].replace("%3A", ":") + return Mock(status_code=200 if path in RESPONSES else 404, json=lambda: RESPONSES.get(path)) + + monkeypatch.setattr("efile.services.efsp_validation.requests.get", get) + yield + cache.clear() + + +def test_usps_fallback_includes_all_62_postal_locations(): + choices, source = state_choices("unknown", "unknown") + assert source == "usps" + assert len(choices) == 62 + assert set(dict(choices)) == set(USPS_STATES) + assert {"DC", "AA", "AE", "AP", "AS", "GU", "MP", "PR", "VI", "FM", "MH", "PW"} <= set(dict(choices)) + + +@pytest.mark.parametrize( + ("jurisdiction", "court", "count"), + [ + ("illinois", "cook:cvd1", 54), + ("massachusetts", "0705:LA", 51), + ("vermont", "6000", 51), + ], +) +def test_recorded_state_choices_match_the_efsp_exactly(jurisdiction, court, count): + choices, source = state_choices(jurisdiction, court) + assert source == "efsp" + assert len(choices) == count + assert set(dict(choices)) == set(RESPONSES[f"{jurisdiction}/codes/courts/{court}/countries/US/states"]) + assert not {"AA", "AE", "AP"}.intersection(dict(choices)) + + +def test_empty_list_means_no_supported_locations_rather_than_fallback(monkeypatch): + monkeypatch.setattr( + "efile.services.efsp_validation.requests.get", lambda *a, **k: Mock(status_code=200, json=lambda: []) + ) + assert state_choices("vermont", "6000") == ([], "efsp") + + +def test_failed_refresh_uses_last_good_then_usps_when_no_cache(monkeypatch): + choices = state_choices("massachusetts", "0705:LA") + for key in [ + "efsp-rule:https://efile-test.suffolklitlab.org/jurisdictions/massachusetts/codes/courts/0705%3ALA/countries/US/states" + ]: + cache.delete(key) + get = Mock(side_effect=requests.Timeout) + monkeypatch.setattr("efile.services.efsp_validation.requests.get", get) + assert state_choices("massachusetts", "0705:LA") == choices + assert state_choices("illinois", "new-court")[1] == "usps" + calls = get.call_count + assert state_choices("illinois", "new-court")[1] == "usps" + assert get.call_count == calls + + +def test_malformed_states_fall_back_and_are_not_kept_as_last_good(monkeypatch): + monkeypatch.setattr( + "efile.services.efsp_validation.requests.get", lambda *a, **k: Mock(status_code=200, json=lambda: [{}]) + ) + assert state_choices("illinois", "cook:cvd1")[1] == "usps" + assert not cache.get( + "efsp-rule:https://efile-test.suffolklitlab.org/jurisdictions/illinois/codes/courts/cook%3Acvd1/countries/US/states:last-good" + ) + + +def test_rules_cache_does_not_cross_courts_or_efsp_environments(settings, monkeypatch): + assert party_validation("massachusetts", "0705:LA")["validation_rules"]["first_name"]["max_length"] == 50 + assert party_validation("illinois", "cook:cvd1")["validation_rules"]["first_name"]["max_length"] is None + settings.EFSP_URL = "https://another-efsp.example" + monkeypatch.setattr("efile.services.efsp_validation.requests.get", Mock(side_effect=requests.Timeout)) + assert state_choices("massachusetts", "0705:LA")[1] == "usps" + + +@pytest.mark.parametrize("phone", ["1234567890", "(123) 456-7890", "+1 1234567890", "+1(123)456-7890", ""]) +def test_massachusetts_phone_validation_matches_proxy_normalization(phone): + errors = validate_party({"phone": phone}, party_validation("massachusetts", "0705:LA")) + assert "phone" not in errors + + +def test_live_messages_examples_and_length_limits(): + metadata = party_validation("massachusetts", "0705:LA") + errors = validate_party({"first_name": "a" * 51, "email": "invalid", "phone": "123"}, metadata) + assert errors["first_name"] == "First name must be 50 characters or fewer." + assert errors["email"] == "Please enter a valid email address: Example: someone@domain.com" + assert errors["phone"] == "Do not use hyphens or other characters--just numbers" + assert metadata["validation_rules"]["phone"]["help"] == "Ex: 1234567890" + + +def test_organization_rules_use_business_name_instead_of_person_name(): + errors = validate_party( + {"organization_name": "a" * 100, "last_name": "b" * 51}, + party_validation("massachusetts", "0705:LA"), + organization=True, + ) + assert not errors + + +@pytest.mark.parametrize( + "pattern", [r"(?i)abc", r"\p{L}+", r"\Qabc\E", r"[a-z&&[^x]]", r"\Aabc\z", r"\d++", r"[\s]", "["] +) +def test_java_only_or_invalid_regexes_are_deferred_to_efsp(pattern): + assert portable_regex(pattern) is None + + +def test_unanchored_regex_uses_search_like_the_proxy(): + metadata = { + "validation_rules": {"email": {"regex": "foo", "message": "bad"}}, + "state_choices": [], + "state_source": "unavailable", + } + assert not validate_party({"email": "afoo@b"}, metadata) + + +def test_payload_validation_catches_extracted_values_before_fees_or_submission(): + payload = {"other_parties": [{"name": {"first": "a" * 51, "last": "Lee"}, "address": {"state": "AE"}}]} + with pytest.raises(PayloadValidationError) as exc: + validate_party_formats(payload, "massachusetts", "0705:LA") + assert isinstance(exc.value, PayloadValidationError) + assert {p["name"] for p in exc.value.problems} == {"other_parties[0].name.first", "other_parties[0].address.state"} + + +@pytest.fixture +def draft(client, django_user_model): + user = django_user_model.objects.create_user(username="validation-user", tyler_jurisdiction="massachusetts") + draft = FilingDraft.objects.create( + user=user, jurisdiction="massachusetts", court_code="0705:LA", workflow_version=2 + ) + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "massachusetts" + session["auth_tokens"] = {"TYLER-TOKEN-MASSACHUSETTS": "token"} + session.save() + return draft + + +@pytest.mark.django_db +def test_filer_form_preserves_invalid_values_and_returns_field_errors(client, draft): + with patch("efile.views.your_information.cached_account_profile", return_value=None): + response = client.post( + reverse("your_information", kwargs={"jurisdiction": draft.jurisdiction}), + { + "first_name": "a" * 51, + "last_name": "Lee", + "address_line_1": "1 Main Street", + "city": "APO", + "state": "AE", + "zip_code": "09001", + "email": "lee@example.com", + "phone": "123", + }, + ) + assert response.status_code == 200 + assert set(response.context["field_errors"]) == {"first_name", "phone", "state"} + assert response.context["filer"].first_name == "a" * 51 + assert not draft.parties.get(role="filer").first_name + content = response.content.decode() + assert 'value="AE" selected disabled' in content + assert "Do not choose a different state." in content + assert 'aria-invalid="true"' in content + + +@pytest.mark.django_db +def test_other_party_form_validates_names_and_keeps_attempted_contact_values(client, draft): + party = FilingParty.objects.create( + draft=draft, role="other", first_name="Morgan", last_name="Lee", party_type="DEF" + ) + with patch("efile.views.party_details.get_party_types", return_value=[{"code": "DEF", "name": "Defendant"}]): + response = client.post( + reverse("party_details", kwargs={"jurisdiction": draft.jurisdiction}) + f"?party={party.pk}", + { + "party_type": "DEF", + "first_name": "a" * 51, + "last_name": "Lee", + "email": "new@example.com", + "phone": "123", + }, + ) + assert response.status_code == 200 + assert set(response.context["field_errors"]) == {"first_name", "phone"} + assert response.context["party"].email == "new@example.com" + party.refresh_from_db() + assert party.first_name == "Morgan" + + +@pytest.mark.django_db +def test_edit_links_follow_payload_parties_and_preserve_draft_identity(draft): + FilingParty.objects.create(draft=draft, role="filer", first_name="Sam", last_name="Jones") + target = FilingParty.objects.create( + draft=draft, role="other", first_name="Lee", last_name="Smith", party_type="DEF", sort_order=9 + ) + payload = {"users": [{"name": {"first": "Lee", "last": "Smith"}, "party_type": "DEF"}]} + actions = error_actions(draft, payload, [{"name": "users[0].address.state", "message": "Bad state"}]) + assert len(actions) == 1 + query = parse_qs(urlsplit(actions[0]["url"]).query) + assert query == {"party": [str(target.pk)], "draft": [str(draft.pk)], "return_to": ["review"], "focus": ["state"]} + assert "Lee Smith" in actions[0]["label"] + + +@pytest.mark.django_db +def test_ambiguous_parties_and_unknown_field_paths_do_not_get_guessed_links(draft): + for index in range(2): + FilingParty.objects.create( + draft=draft, role="other", sort_order=index, first_name="Lee", last_name="Smith", party_type="DEF" + ) + payload = {"other_parties": [{"name": {"first": "Lee", "last": "Smith"}, "party_type": "DEF"}]} + assert error_actions(draft, payload, [{"name": "other_parties[0].address.state"}]) == [] + assert error_actions(draft, payload, [{"name": "other_parties[999].address.state"}, {"name": "unknown"}]) == [] + + +@pytest.mark.django_db +def test_legacy_state_message_locates_unique_rejected_value(draft): + FilingParty.objects.create(draft=draft, role="other", first_name="Lee", last_name="Smith", party_type="DEF") + payload = { + "other_parties": [{"name": {"first": "Lee", "last": "Smith"}, "party_type": "DEF", "address": {"state": "BAD"}}] + } + actions = error_actions(draft, payload, [], message="Opposing party dosesn't support a state named BAD") + assert len(actions) == 1 + assert "focus=state" in actions[0]["url"] + + +def test_nested_proxy_error_and_name_length_regex_are_parsed(): + response = Mock(status_code=400) + response.json.return_value = { + "error": {"name": "users[0].name.last", "currentVal": "a" * 51, "description": ": must match regex: ^.{0,50}$"} + } + assert "50 characters or fewer" in describe_efsp_error(response) + assert efsp_error_problems(response)[0]["name"] == "users[0].name.last" + + +@pytest.mark.django_db +@pytest.mark.parametrize("prevalidate", [True, False]) +def test_fee_errors_return_precise_actions_for_both_local_and_upstream_rejections(client, draft, prevalidate): + party = FilingParty.objects.create(draft=draft, role="other", first_name="Lee", last_name="Smith", party_type="DEF") + payload = { + "other_parties": [ + { + "name": {"first": "Lee", "last": "Smith"}, + "party_type": "DEF", + "address": {"state": "AE" if prevalidate else "MA"}, + } + ], + "al_court_bundle": [], + } + response = Mock(status_code=400, text="Rejected") + response.json.return_value = {"wrong_vars": [{"name": "other_parties[0].name.last", "currentVal": "Smith"}]} + with patch("efile.api.filing_views.requests.post", return_value=response) as post: + result = client.post( + reverse("api:payment_fees"), json.dumps({"efile_data": payload}), content_type="application/json" + ) + assert result.status_code == 400 + actions = result.json()["error_actions"] + assert len(actions) == 1 + params = parse_qs(urlsplit(actions[0]["url"]).query) + assert params["party"] == [str(party.pk)] + assert params["focus"] == ["state" if prevalidate else "last_name"] + assert post.called is not prevalidate + + +def test_registration_uses_system_state_codes_and_explains_missing_addresses(): + from efile.forms import EFileRegistrationForm + + with patch("efile.forms.state_choices", return_value=([("DC", "District of Columbia")], "efsp")): + form = EFileRegistrationForm(jurisdiction="massachusetts") + field = form.fields["state"] + assert isinstance(field, ChoiceField) + assert list(field.choices) == [("", "Select a state"), ("DC", "District of Columbia")] + assert "Do not choose a different state" in form.fields["state"].help_text + + +def test_foreign_addresses_never_get_usps_choices_during_an_outage(): + assert state_choices("massachusetts", "0705:LA", "CA") == ([], "unavailable") diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py index 3704892a..34b624b0 100644 --- a/efile_app/efile/tests/test_people_flow.py +++ b/efile_app/efile/tests/test_people_flow.py @@ -725,9 +725,9 @@ def test_your_information_defaults_the_state_to_this_jurisdiction(client, django with patch("efile.views.your_information.cached_account_profile", return_value=None): response = client.get(reverse("your_information", kwargs={"jurisdiction": jurisdiction})) - state_input = re.search(r']*name="state"[^>]*>', response.content.decode()) - assert state_input is not None - assert f'value="{state_code}"' in state_input.group() + content = response.content.decode() + assert re.search(r']*name="state"[^>]*>', content) + assert re.search(rf'