diff --git a/src/openforms/contrib/brk/validators.py b/src/openforms/contrib/brk/validators.py index 7aab722c15..94702c7308 100644 --- a/src/openforms/contrib/brk/validators.py +++ b/src/openforms/contrib/brk/validators.py @@ -11,6 +11,7 @@ from rest_framework import serializers from openforms.authentication.constants import AuthAttribute +from openforms.formio.components.custom import AddressValueSerializer from openforms.submissions.models import Submission from openforms.validations.base import BasePlugin from openforms.validations.registry import register @@ -32,27 +33,6 @@ def suppress_api_errors(error_message: str) -> Iterator[None]: raise ValidationError(error_message) from e -class AddressValueSerializer(serializers.Serializer): - postcode = serializers.RegexField( - "^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$", - ) - house_number = serializers.RegexField( - r"^\d{1,5}$", - ) - house_letter = serializers.RegexField( - "^[a-zA-Z]$", required=False, allow_blank=True - ) - house_number_addition = serializers.RegexField( - "^([a-z,A-Z,0-9]){1,4}$", - required=False, - allow_blank=True, - ) - - def validate_postcode(self, value: str) -> str: - """Normalize the postcode so that it matches the regex from the BRK API.""" - return value.upper().replace(" ", "") - - class ValueSerializer(serializers.Serializer): value = AddressValueSerializer() @@ -97,12 +77,12 @@ def __call__(self, value: AddressValue, submission: Submission) -> bool: address_query: SearchParams = { "postcode": value["postcode"], - "huisnummer": value["house_number"], + "huisnummer": value["houseNumber"], } if "house_letter" in value: - address_query["huisletter"] = value["house_letter"] + address_query["huisletter"] = value["houseLetter"] if "house_number_addition" in value: - address_query["huisnummertoevoeging"] = value["house_number_addition"] + address_query["huisnummertoevoeging"] = value["houseNumberAddition"] with client, suppress_api_errors(self.error_messages["retrieving_error"]): real_estate_objects_resp = client.get_real_estate_by_address(address_query) diff --git a/src/openforms/formio/components/custom.py b/src/openforms/formio/components/custom.py index b478bfe750..aecbf5509b 100644 --- a/src/openforms/formio/components/custom.py +++ b/src/openforms/formio/components/custom.py @@ -377,11 +377,46 @@ def build_serializer_field( return serializers.ListField(child=base) if multiple else base +class AddressValueSerializer(serializers.Serializer): + postcode = serializers.RegexField( + "^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$", + ) + houseNumber = serializers.RegexField( + r"^\d{1,5}$", + ) + houseLetter = serializers.RegexField("^[a-zA-Z]$", required=False, allow_blank=True) + houseNumberAddition = serializers.RegexField( + "^([a-z,A-Z,0-9]){1,4}$", + required=False, + allow_blank=True, + ) + + def validate_postcode(self, value: str) -> str: + """Normalize the postcode so that it matches the regex from the BRK API.""" + return value.upper().replace(" ", "") + + @register("addressNL") class AddressNL(BasePlugin): formatter = AddressNLFormatter + def build_serializer_field(self, component: Component) -> AddressValueSerializer: + + validate = component.get("validate", {}) + required = validate.get("required", False) + + extra = {} + validators = [] + if plugin_ids := validate.get("plugins", []): + validators.append(PluginValidator(plugin_ids)) + + extra["validators"] = validators + + return AddressValueSerializer( + required=required, allow_null=not required, **extra + ) + @register("cosign") class Cosign(BasePlugin): diff --git a/src/openforms/formio/tests/validation/test_addressnl.py b/src/openforms/formio/tests/validation/test_addressnl.py new file mode 100644 index 0000000000..71abe21736 --- /dev/null +++ b/src/openforms/formio/tests/validation/test_addressnl.py @@ -0,0 +1,163 @@ +from django.test import SimpleTestCase + +from rest_framework import serializers + +from openforms.contrib.brk.constants import AddressValue +from openforms.contrib.brk.validators import ValueSerializer +from openforms.submissions.models import Submission +from openforms.validations.base import BasePlugin + +from ...typing import Component +from .helpers import extract_error, replace_validators_registry, validate_formio_data + + +class PostcodeValidator(BasePlugin[AddressValue]): + value_serializer = ValueSerializer + + def __call__(self, value: AddressValue, submission: Submission): + if value["postcode"] == "1234AA": + raise serializers.ValidationError("nope") + + +class AddressNLValidationTests(SimpleTestCase): + + def test_addressNL_field_required_validation(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "Required AddressNL", + "validate": {"required": True}, + } + + invalid_values = [ + ({}, "required"), + ({"addressNl": None}, "null"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) + + def test_addressNL_field_non_required_validation(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "Non required AddressNL", + } + + is_valid, _ = validate_formio_data(component, {}) + + self.assertTrue(is_valid) + + def test_addressNL_field_regex_pattern_failure(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "AddressNL invalid regex", + } + + invalid_values = { + "addressNl": { + "postcode": "123456wrong", + "houseNumber": "", + "houseLetter": "A", + "houseNumberAddition": "", + } + } + + is_valid, errors = validate_formio_data(component, invalid_values) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + + error = extract_error(errors["addressNl"], "postcode") + + self.assertEqual(error.code, "invalid") + + def test_addressNL_field_regex_pattern_success(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "AddressNL valid pattern", + } + + data = { + "addressNl": { + "postcode": "1234AA", + "houseNumber": "2", + "houseLetter": "A", + "houseNumberAddition": "", + } + } + + is_valid, _ = validate_formio_data(component, data) + + self.assertTrue(is_valid) + + def test_missing_keys(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "AddressNL missing keys", + } + + invalid_values = { + "addressNl": { + "houseLetter": "A", + } + } + + is_valid, errors = validate_formio_data(component, invalid_values) + + postcode_error = extract_error(errors["addressNl"], "postcode") + house_number_error = extract_error(errors["addressNl"], "houseNumber") + + self.assertFalse(is_valid) + self.assertEqual(postcode_error.code, "required") + self.assertEqual(house_number_error.code, "required") + + def test_plugin_validator(self): + with replace_validators_registry() as register: + register("postcode_validator")(PostcodeValidator) + + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "AddressNL plugin validator", + "validate": {"plugins": ["postcode_validator"]}, + } + + with self.subTest("valid value"): + is_valid, _ = validate_formio_data( + component, + { + "addressNl": { + "postcode": "9877AA", + "houseNumber": "3", + "houseLetter": "A", + "houseNumberAddition": "", + } + }, + ) + + self.assertTrue(is_valid) + + with self.subTest("invalid value"): + is_valid, _ = validate_formio_data( + component, + { + "addressNl": { + "postcode": "1234AA", + "houseNumber": "3", + "houseLetter": "A", + "houseNumberAddition": "", + } + }, + ) + + self.assertFalse(is_valid) diff --git a/src/openforms/submissions/parsers.py b/src/openforms/submissions/parsers.py index 8c5eb8f7c5..cfc4787f91 100644 --- a/src/openforms/submissions/parsers.py +++ b/src/openforms/submissions/parsers.py @@ -6,6 +6,10 @@ class IgnoreDataAndConfigFieldCamelCaseJSONParser(CamelCaseJSONParser): json_underscoreize = {"ignore_fields": ("data", "configuration")} +class IgnoreValueFieldCamelCaseJSONParser(CamelCaseJSONParser): + json_underscoreize = {"ignore_fields": ("value",)} + + class IgnoreDataAndConfigJSONRenderer(CamelCaseJSONRenderer): # This is needed for fields in the submission step data that have keys with underscores json_underscoreize = {"ignore_fields": ("data", "configuration")} diff --git a/src/openforms/tests/e2e/test_input_validation.py b/src/openforms/tests/e2e/test_input_validation.py index d644384ce8..33abe7814d 100644 --- a/src/openforms/tests/e2e/test_input_validation.py +++ b/src/openforms/tests/e2e/test_input_validation.py @@ -934,3 +934,117 @@ def test_forbidden_file_type(self): # Make sure the frontend did not create one: self.assertEqual(TemporaryFileUpload.objects.count(), 1) + + +class SingleAddressNLTests(ValidationsTestCase): + fuzzy_match_invalid_param_names = True + + def assertAddressNLValidationIsAligned( + self, + component: Component, + ui_inputs: dict[str, str], + expected_ui_error: str, + api_value: dict[str, Any], + ) -> None: + form = create_form(component) + + with self.subTest("frontend validation"): + self._assertAddressNLFrontendValidation(form, ui_inputs, expected_ui_error) + + with self.subTest("backend validation"): + self._assertBackendValidation(form, component["key"], api_value) + + @async_to_sync + async def _assertAddressNLFrontendValidation( + self, form: Form, ui_inputs: dict[str, str], expected_ui_error: str + ) -> None: + frontend_path = reverse("forms:form-detail", kwargs={"slug": form.slug}) + url = str(furl(self.live_server_url) / frontend_path) + + async with browser_page() as page: + await page.goto(url) + await page.get_by_role("button", name="Formulier starten").click() + + for field, value in ui_inputs.items(): + await page.fill(f"input[name='{field}']", value) + + # try to submit the step which should be invalid, so we expect this to + # render the error message. + await page.get_by_role("button", name="Volgende").click() + + await expect(page.get_by_text(expected_ui_error)).to_be_visible() + + def test_required_field(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "Required AddressNL", + "validate": {"required": True}, + } + + self.assertAddressNLValidationIsAligned( + component, + ui_inputs={}, + api_value={ + "postcode": "", + "houseNumber": "", + "houseLetter": "", + "houseNumberAddition": "", + }, + expected_ui_error="Het verplichte veld Required AddressNL is niet ingevuld.", + ) + + def test_regex_failure(self): + component: Component = { + "key": "addressNl", + "type": "addressNL", + "label": "AddressNL invalid regex", + } + + test_cases = [ + ( + "postcode", + { + "postcode": "1223456Wrong", + "houseNumber": "23", + "houseLetter": "A", + "houseNumberAddition": "", + }, + ), + ( + "houseNumber", + { + "postcode": "1234AA", + "houseNumber": "A", + "houseLetter": "A", + "houseNumberAddition": "", + }, + ), + ( + "houseLetter", + { + "postcode": "1234AA", + "houseNumber": "33", + "houseLetter": "89", + "houseNumberAddition": "", + }, + ), + ( + "houseNumberAddition", + { + "postcode": "1234AA", + "houseNumber": "33", + "houseLetter": "A", + "houseNumberAddition": "9999A", + }, + ), + ] + + for field_name, invalid_data in test_cases: + with self.subTest(field_name): + self.assertAddressNLValidationIsAligned( + component, + ui_inputs=invalid_data, + api_value=invalid_data, + expected_ui_error="Ongeldig.", + ) diff --git a/src/openforms/validations/api/views.py b/src/openforms/validations/api/views.py index 9e3c000585..6e698d2fac 100644 --- a/src/openforms/validations/api/views.py +++ b/src/openforms/validations/api/views.py @@ -12,6 +12,7 @@ from openforms.api.views import ListMixin from openforms.submissions.api.permissions import owns_submission from openforms.submissions.models import Submission +from openforms.submissions.parsers import IgnoreValueFieldCamelCaseJSONParser from openforms.validations.api.serializers import ( ValidationInputSerializer, ValidationPluginSerializer, @@ -59,6 +60,7 @@ class ValidationView(APIView): """ authentication_classes = (AnonCSRFSessionAuthentication,) + parser_classes = [IgnoreValueFieldCamelCaseJSONParser] @extend_schema( operation_id="validation_run",