diff --git a/domain/postcode.py b/domain/postcode.py index 8e4e7c797..06135555f 100644 --- a/domain/postcode.py +++ b/domain/postcode.py @@ -1,7 +1,13 @@ from __future__ import annotations +import re from dataclasses import dataclass +# UK postcode format, matched against the canonical (no-space, upper) value. +# A pure structural check — it does not confirm the postcode actually exists +# (that would need a network lookup, which a value object must not do). +_UK_POSTCODE_RE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2}") + @dataclass(frozen=True) class Postcode: @@ -13,3 +19,8 @@ class Postcode: def __str__(self) -> str: return self.value + + def is_valid(self) -> bool: + """Whether the canonical value is a well-formed UK postcode (format + only — no existence check, so it is pure and offline).""" + return _UK_POSTCODE_RE.fullmatch(self.value) is not None diff --git a/tests/domain/test_postcode.py b/tests/domain/test_postcode.py index f7ce90150..ebed1e2d4 100644 --- a/tests/domain/test_postcode.py +++ b/tests/domain/test_postcode.py @@ -57,3 +57,18 @@ def test_postcode_is_frozen() -> None: # act / assert with pytest.raises(dataclasses.FrozenInstanceError): postcode.value = "OTHER" # type: ignore[misc] + + +@pytest.mark.parametrize( + "raw", + ["sw1a 1aa", "M1 1AA", "b33 8th", "cr2 6xh", "dn55 1pt", "ec1a 1bb", "w1a 0ax"], +) +def test_valid_uk_postcode_formats_are_valid(raw: str) -> None: + # act / assert — a pure format check on the canonical value, no network. + assert Postcode(raw).is_valid() is True + + +@pytest.mark.parametrize("raw", ["", " ", "NONSENSE", "12345", "SW1A 1A", "ZZ"]) +def test_malformed_or_empty_postcodes_are_invalid(raw: str) -> None: + # act / assert + assert Postcode(raw).is_valid() is False