diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f8a31ae4..2a61bebb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1911,6 +1911,7 @@ class EpcPropertyDataMapper: """ data = _normalize_shower_outlets(data) + data = _default_missing_post_town(data) schema = data.get("schema_type", "") if schema == "RdSAP-Schema-21.0.1": from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 @@ -2081,6 +2082,26 @@ def _normalize_shower_outlets(data: Dict[str, Any]) -> Dict[str, Any]: return {**data, "sap_heating": new_sap_heating} +def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]: + """Default an absent top-level `post_town` to "" before `from_dict`. + + `RdSapSchema21_0_x.post_town` is a required (no-default) field, so a + real-API cert that omits it (observed on a 2026-register cert whose + town sits only in `address_line_3`) makes `from_dict` raise + "missing required field 'post_town'", blocking the whole cert. + `post_town` is address metadata that the SAP cascade never reads, so + defaulting it to "" is inert for the calculation while keeping the + cert mappable. The schema dataclass can't simply give the field a + default — it is a plain (non-kw_only) dataclass with 57 required + fields after `post_town`, so a mid-list default would break field + ordering; pre-processing here mirrors `_normalize_shower_outlets`. + + Mutates a shallow copy so the caller's dict is untouched.""" + if "post_town" in data: + return data + return {**data, "post_town": ""} + + def _count_shower_outlets_by_type( schema_shower_outlets: Any, target_type: int, ) -> Optional[int]: diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 4f0b80ee..b0711d9f 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -849,3 +849,33 @@ class TestApiFloorConstructionCode: # Assert — no raise; None defers to the cascade's Table 19 default. assert result is None + + +class TestDefaultMissingPostTown: + """`_default_missing_post_town` keeps a cert mappable when the + register omits the required `post_town` field (observed on a 2026 + cert whose town sits only in address_line_3). post_town is address + metadata the SAP cascade never reads, so defaulting it to "" before + `from_dict` is inert for the calculation.""" + + def test_absent_post_town_is_defaulted_to_empty_string(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"postcode": "EX31 2LE"} + + # Act + result = _default_missing_post_town(doc) + + # Assert + assert result["post_town"] == "" + + def test_present_post_town_is_untouched(self) -> None: + # Arrange — regression guard: a lodged town passes through. + from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"post_town": "BARNSTAPLE"} + + # Act + result = _default_missing_post_town(doc) + + # Assert + assert result["post_town"] == "BARNSTAPLE"