Model/tests/e2e/test_epc_prediction_e2e.py
Jun-te Kim 80b86d4790 Prove prediction resolves landlord overrides to a real cohort match end-to-end 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:20:49 +00:00

201 lines
7.8 KiB
Python

"""END-TO-END showcase: an EPC-less Property flows through Ingestion, gets a
predicted EPC synthesised from its postcode cohort, is persisted to the predicted
slot, and comes back out of the Property repository resolving as the Effective
EPC (ADR-0031).
This is the full production path with ONLY the external HTTP clients faked (the
EPC API, the geospatial S3 reader, the Solar API) — everything else is the real
thing: the real Postgres Unit of Work, the real EPC + Property repositories
against the test database, the real `EpcComparablePropertiesRepository`, and the
real `EpcPrediction`. It is the canonical "see the whole flow" reference; the
narrower unit tests live in:
- tests/orchestration/test_ingestion_prediction.py (orchestrator: gate / persist)
- tests/repositories/epc/test_epc_predicted_slot.py (the lodged|predicted slot)
- tests/domain/property/test_property.py (the "predicted" source path)
- tests/domain/epc_prediction/test_prediction_target.py (the eligibility gate)
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
from sqlalchemy import Engine
from sqlmodel import Session
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from datatypes.epc.search.epc_search_result import EpcSearchResult
from domain.epc_prediction.epc_prediction import EpcPrediction
from domain.geospatial.coordinates import Coordinates
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.geospatial.spatial_reference import SpatialReference
from domain.property.property import Property
from infrastructure.postgres.property_override_table import PropertyOverrideRow
from infrastructure.postgres.property_table import PropertyRow
from orchestration.ingestion_orchestrator import IngestionOrchestrator
from repositories.comparable_properties.epc_comparable_properties_repository import (
EpcComparablePropertiesRepository,
)
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
from repositories.geospatial.geospatial_repository import GeospatialRepository
from repositories.postgres_unit_of_work import PostgresUnitOfWork
from repositories.property.override_backed_prediction_attributes_reader import (
OverrideBackedPredictionAttributesReader,
)
from repositories.property.property_overrides_postgres_reader import (
PropertyOverridesPostgresReader,
)
from repositories.property.property_postgres_repository import (
PropertyPostgresRepository,
)
from repositories.spatial.spatial_postgres_repository import SpatialPostgresRepository
_JSON_SAMPLES = Path(__file__).resolve().parents[2] / "backend/epc_api/json_samples"
_POSTCODE = "LS6 1AA"
def _epc() -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
# --- fakes for the THREE external HTTP boundaries (everything else is real) ----
class _FakeCohortEpcClient:
"""Stands in for the live EPC API: the postcode's lodged certs + their data."""
def __init__(self, results: list[EpcSearchResult]) -> None:
self._results = results
def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]:
return self._results
def get_by_certificate_number(self, cert_num: str) -> EpcPropertyData:
return _epc()
class _FakeGeospatialRepo(GeospatialRepository):
"""Stands in for the S3 Open-UPRN reader: UPRN → coordinates."""
def __init__(self, coords: dict[int, Coordinates]) -> None:
self._coords = coords
def coordinates_for(self, uprn: int) -> Optional[Coordinates]:
return self._coords.get(uprn)
def spatial_for(self, uprn: int) -> Optional[SpatialReference]:
coordinates = self._coords.get(uprn)
if coordinates is None:
return None
return SpatialReference(
coordinates=coordinates, restrictions=PlanningRestrictions()
)
class _NoEpcFetcher:
"""The target Property is EPC-less — the EPC API finds nothing for its UPRN."""
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
return None
class _NoSolarFetcher:
def get_building_insights(
self, longitude: float, latitude: float
) -> dict[str, Any]:
return {}
def _cohort_results() -> list[EpcSearchResult]:
return [
EpcSearchResult(
certificate_number=f"CERT-{i}",
address_line_1=f"{i} Neighbour Road",
address_line_2=None,
address_line_3=None,
address_line_4=None,
postcode=_POSTCODE,
post_town="LEEDS",
uprn=20000 + i,
current_energy_efficiency_band="D",
registration_date=f"2023-0{i + 1}-01",
)
for i in range(3)
]
def test_epc_less_property_is_predicted_persisted_and_resolved_end_to_end(
db_engine: Engine,
) -> None:
# Arrange — an EPC-less Property exists in the database (postcode + UPRN known,
# no EPC lodged), plus its postcode cohort behind the faked EPC API, plus the
# landlord overrides the finaliser resolved for it (House / Semi-Detached) that
# the real read adapter will translate into the gov-code space ("0" / "2").
with Session(db_engine) as session:
row = PropertyRow(
portfolio_id=1, postcode=_POSTCODE, address="1 Target Street", uprn=10000
)
session.add(row)
session.commit()
property_id = row.id
assert property_id is not None
session.add(
PropertyOverrideRow(
property_id=property_id,
portfolio_id=1,
building_part=0,
override_component="property_type",
override_value="House",
original_spreadsheet_description="3-bed semi",
)
)
session.add(
PropertyOverrideRow(
property_id=property_id,
portfolio_id=1,
building_part=0,
override_component="built_form_type",
override_value="Semi-Detached",
original_spreadsheet_description="3-bed semi",
)
)
session.commit()
cohort_coords = {20000 + i: Coordinates(longitude=-1.55, latitude=53.81) for i in range(3)}
comparables_repo = EpcComparablePropertiesRepository(
_FakeCohortEpcClient(_cohort_results()), _FakeGeospatialRepo(cohort_coords)
)
orchestrator = IngestionOrchestrator(
unit_of_work=lambda: PostgresUnitOfWork(lambda: Session(db_engine)),
epc_fetcher=_NoEpcFetcher(),
geospatial_repo=_FakeGeospatialRepo({10000: Coordinates(longitude=-1.55, latitude=53.81)}),
solar_fetcher=_NoSolarFetcher(),
comparables_repo=comparables_repo,
prediction_attributes_reader=OverrideBackedPredictionAttributesReader(
PropertyOverridesPostgresReader(lambda: Session(db_engine))
),
epc_prediction=EpcPrediction(),
)
# Act — run Ingestion: no lodged EPC found → predict from the cohort → persist.
orchestrator.run([property_id])
# Assert — reloading the Property through the real repository, its Effective
# EPC is the predicted picture, flagged by the "predicted" source path.
with Session(db_engine) as session:
epc_repo = EpcPostgresRepository(session)
prop: Property = PropertyPostgresRepository(
session, epc_repo, SpatialPostgresRepository(session)
).get(property_id)
assert prop.epc is None # no lodged EPC
assert prop.predicted_epc is not None # a predicted one was persisted
assert prop.source_path == "predicted"
assert prop.effective_epc is prop.predicted_epc
assert prop.effective_epc.property_type == "0"