diff --git a/legal-api/src/legal_api/services/filings/validations/common_validations.py b/legal-api/src/legal_api/services/filings/validations/common_validations.py index 69f86bf786..6d255dbc88 100644 --- a/legal-api/src/legal_api/services/filings/validations/common_validations.py +++ b/legal-api/src/legal_api/services/filings/validations/common_validations.py @@ -52,6 +52,11 @@ "postalCode", ) +CANADIAN_POSTAL_CODE_REGEX = re.compile( + r"^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$", + re.IGNORECASE +) + PARTY_NAME_MAX_LENGTH = 30 # Share structure constants @@ -789,7 +794,7 @@ def _validate_postal_code( address: dict, address_path: str ) -> dict: - """Validate that postal code is optional for specified country.""" + """Validate postal code presence and format for the given country.""" country = address["addressCountry"] postal_code = address.get("postalCode") try: @@ -798,6 +803,13 @@ def _validate_postal_code( not postal_code: return {"error": _("Postal code is required."), "path": f"{address_path}/postalCode"} + + # Canadian postal code format validation + if country == "CA" and postal_code and not CANADIAN_POSTAL_CODE_REGEX.match(postal_code): + return { + "error": _("Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'."), + "path": f"{address_path}/postalCode" + } except LookupError: # Different ISO-2 country validations are done at filing level, # this can be refactored into a common validator in the future diff --git a/legal-api/tests/unit/services/filings/validations/change_of_director/test_validation_basic.py b/legal-api/tests/unit/services/filings/validations/change_of_director/test_validation_basic.py index e78fe2cbad..9f3274725c 100644 --- a/legal-api/tests/unit/services/filings/validations/change_of_director/test_validation_basic.py +++ b/legal-api/tests/unit/services/filings/validations/change_of_director/test_validation_basic.py @@ -212,7 +212,9 @@ def test_validate_cod_basic(session, test_name, now, {"streetAddress": "456 B St", "addressCity": "Victoria", "addressCountry": "CA", "postalCode": "V8W1C2"}, HTTPStatus.BAD_REQUEST, [ {'error': 'postalCode cannot start or end with whitespace.', - 'path': '/filing/changeOfDirectors/directors/0/deliveryAddress/postalCode'} + 'path': '/filing/changeOfDirectors/directors/0/deliveryAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", + 'path': '/filing/changeOfDirectors/directors/0/deliveryAddress/postalCode'}, ] ), ( @@ -248,6 +250,8 @@ def test_validate_cod_basic(session, test_name, now, {"streetAddress": "456 B St", "addressCity": "Victoria", "addressCountry": "CA", "postalCode": " V8W1C2 "}, HTTPStatus.BAD_REQUEST, [ {'error': 'postalCode cannot start or end with whitespace.', + 'path': '/filing/changeOfDirectors/directors/0/mailingAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", 'path': '/filing/changeOfDirectors/directors/0/mailingAddress/postalCode'} ] ), @@ -258,7 +262,11 @@ def test_validate_cod_basic(session, test_name, now, HTTPStatus.BAD_REQUEST, [ {'error': 'postalCode cannot start or end with whitespace.', 'path': '/filing/changeOfDirectors/directors/0/deliveryAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", + 'path': '/filing/changeOfDirectors/directors/0/deliveryAddress/postalCode'}, {'error': 'postalCode cannot start or end with whitespace.', + 'path': '/filing/changeOfDirectors/directors/0/mailingAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", 'path': '/filing/changeOfDirectors/directors/0/mailingAddress/postalCode'} ] ), diff --git a/legal-api/tests/unit/services/filings/validations/test_common_validations.py b/legal-api/tests/unit/services/filings/validations/test_common_validations.py index abb05b1c3a..7dd8538180 100644 --- a/legal-api/tests/unit/services/filings/validations/test_common_validations.py +++ b/legal-api/tests/unit/services/filings/validations/test_common_validations.py @@ -256,7 +256,81 @@ def test_validate_offices_addresses(session, filing_type, filing_data, office_ty error_fields = {e['path'].split('/')[-1] for e in err4} assert error_fields == set(WHITESPACE_VALIDATED_ADDRESS_FIELDS) - + +@pytest.mark.parametrize('filing_type, filing_data, office_type', [ + ('amaglamationApplication', AMALGAMATION_APPLICATION, 'registeredOffice'), + ('changeOfAddress', CHANGE_OF_ADDRESS, 'registeredOffice'), + ('changeOfLiquidators', CHANGE_OF_LIQUIDATORS, 'liquidationRecordsOffice'), + ('changeOfRegistration', CHANGE_OF_REGISTRATION, 'businessOffice'), + ('continuationIn', CONTINUATION_IN, 'registeredOffice'), + ('conversion', FIRMS_CONVERSION, 'businessOffice'), + ('correction', CORRECTION, 'registeredOffice'), + ('incorporationApplication', INCORPORATION, 'registeredOffice'), + ('registration', REGISTRATION, 'businessOffice'), + ('restoration', RESTORATION, 'registeredOffice') +]) +@pytest.mark.parametrize('postal_code, expected_valid', [ + # Valid cases + ('V6B 1A1', True), # with space + ('V6B1A1', True), # without space + ('v6b 1a1', True), # lowercase + ('v6B1a1', True), # mixed case + ('K1N 3H9', True), # different province + + # Invalid cases + ('V6B\t1A1', False), # tab + ('V6B\n1A1', False), # newline + ('12345', False), # US zip code + ('V6B 1A1', False), # double space + ('V6B', False), # too short + ('V6B 1A1X', False), # too long + ('VV6 1A1', False), # wrong character positions + + # Invalid cases with disallowed first letters (D, F, I, O, Q, U, W) + ('D6B 1A1', False), + ('F6B 1A1', False), + ('I6B 1A1', False), + ('O6B 1A1', False), + ('Q6B 1A1', False), + ('U6B 1A1', False), + ('W6B 1A1', False), + +]) +def test_validate_offices_addresses_canadian_postal_code(session, filing_type, filing_data, office_type, postal_code, expected_valid): + """Test Canadian postal code format validation for office addresses.""" + filing = copy.deepcopy(FILING_HEADER) + filing['filing'][filing_type] = copy.deepcopy(filing_data) + + address = { + 'streetAddress': '123 Main St', + 'addressCity': 'Vancouver', + 'addressCountry': 'CA', + 'postalCode': postal_code, + 'addressRegion': 'BC' + } + filing['filing'][filing_type]['offices'][office_type]['deliveryAddress'] = address + + errs = validate_offices_addresses(filing, filing_type) + + if expected_valid: + assert errs == [] + else: + assert errs + assert errs[0]['error'] == "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'." + assert 'postalCode' in errs[0]['path'] + + +def test_validate_offices_addresses_non_ca_postal_code_not_validated(session): + """Test that non-Canadian addresses are not subject to Canadian postal code format validation.""" + filing = copy.deepcopy(FILING_HEADER) + filing_type = 'incorporationApplication' + filing['filing'][filing_type] = copy.deepcopy(INCORPORATION) + + # A US zip code on a US address should not trigger Canadian format validation + filing['filing'][filing_type]['offices']['registeredOffice']['deliveryAddress'] = VALID_ADDRESS_EX_CA + errs = validate_offices_addresses(filing, filing_type) + assert errs == [] + @pytest.mark.parametrize('filing_type, filing_data, party_key', [ ('amaglamationApplication', AMALGAMATION_APPLICATION, 'parties'), diff --git a/legal-api/tests/unit/services/filings/validations/test_incorporation_application.py b/legal-api/tests/unit/services/filings/validations/test_incorporation_application.py index ab2faab707..7aaf4e71d7 100644 --- a/legal-api/tests/unit/services/filings/validations/test_incorporation_application.py +++ b/legal-api/tests/unit/services/filings/validations/test_incorporation_application.py @@ -333,7 +333,9 @@ def test_validate_incorporation_addresses_basic(session, mocker, test_name, lega {"streetAddress": "456 B St", "addressCity": "Victoria", "addressCountry": "CA", "addressRegion": "BC", "postalCode": "V8W1C2"}, HTTPStatus.BAD_REQUEST, [ {'error': 'postalCode cannot start or end with whitespace.', - 'path': '/filing/incorporationApplication/offices/registeredOffice/deliveryAddress/postalCode'} + 'path': '/filing/incorporationApplication/offices/registeredOffice/deliveryAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", + 'path': '/filing/incorporationApplication/offices/registeredOffice/deliveryAddress/postalCode'}, ] ), ( @@ -369,7 +371,9 @@ def test_validate_incorporation_addresses_basic(session, mocker, test_name, lega {"streetAddress": "456 B St", "addressCity": "Victoria", "addressCountry": "CA", "addressRegion": "BC", "postalCode": " V8W1C2 "}, HTTPStatus.BAD_REQUEST, [ {'error': 'postalCode cannot start or end with whitespace.', - 'path': '/filing/incorporationApplication/offices/registeredOffice/mailingAddress/postalCode'} + 'path': '/filing/incorporationApplication/offices/registeredOffice/mailingAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", + 'path': '/filing/incorporationApplication/offices/registeredOffice/mailingAddress/postalCode'}, ] ), ( @@ -379,7 +383,11 @@ def test_validate_incorporation_addresses_basic(session, mocker, test_name, lega HTTPStatus.BAD_REQUEST, [ {'error': 'postalCode cannot start or end with whitespace.', 'path': '/filing/incorporationApplication/offices/registeredOffice/deliveryAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", + 'path': '/filing/incorporationApplication/offices/registeredOffice/deliveryAddress/postalCode'}, {'error': 'postalCode cannot start or end with whitespace.', + 'path': '/filing/incorporationApplication/offices/registeredOffice/mailingAddress/postalCode'}, + {'error': "Postal code must follow Canadian format, e.g. 'A1A 1A1' or 'A1A1A1'.", 'path': '/filing/incorporationApplication/offices/registeredOffice/mailingAddress/postalCode'} ] ),