"""Tests for the modelling_e2e Lambda handler. Tests exercise the handler's external behaviour through handler.__wrapped__, patching I/O boundaries (EPC client, DB reads, UoW) so no real DB or network is needed. One test per distinct behaviour path. """ from __future__ import annotations from contextlib import ExitStack from typing import Any from unittest.mock import MagicMock, call, patch import pytest from pydantic import ValidationError from applications.modelling_e2e.modelling_e2e_trigger_body import ( ModellingE2ETriggerBody, ) PROPERTY_ID = 12345 UPRN = 987654321 POSTCODE = "SW1A 1AA" PORTFOLIO_ID = 100 SCENARIO_ID = 200 _ENV = { "OPEN_EPC_API_TOKEN": "test-token", "DATA_BUCKET": "test-bucket", "GOOGLE_SOLAR_API_KEY": "test-solar-key", } _BODY = { "property_ids": [PROPERTY_ID], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, "no_solar": True, "dry_run": False, } def _call_handler(body: dict[str, Any]) -> Any: from applications.modelling_e2e.handler import handler return handler.__wrapped__(body, None) # type: ignore[attr-defined] def _engine_mock( property_ids: list[int], uprns: list[int], postcodes: list[str], ) -> MagicMock: """Mock engine whose connect() returns UPRN then postcode rows.""" mock_engine = MagicMock() mock_conn = mock_engine.connect.return_value.__enter__.return_value uprn_result = MagicMock() uprn_result.fetchall.return_value = list(zip(property_ids, uprns)) postcode_result = MagicMock() postcode_result.fetchall.return_value = list(zip(property_ids, postcodes)) mock_conn.execute.side_effect = [uprn_result, postcode_result] return mock_engine def _plan_mock() -> MagicMock: plan = MagicMock() plan.measures = [] plan.cost_of_works = 0.0 return plan # --------------------------------------------------------------------------- # Fixture: clear module-level cohort cache between tests # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _clear_cohort_cache() -> None: import applications.modelling_e2e.handler as h h._cohort_cache.clear() # --------------------------------------------------------------------------- # Trigger body validation # --------------------------------------------------------------------------- def test_trigger_body_requires_property_ids() -> None: """property_ids (plural) must be provided and must be a list of ints.""" # Arrange body = { "property_ids": [1, 2], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, } # Act result = ModellingE2ETriggerBody.model_validate(body) # Assert assert result.property_ids == [1, 2] def test_trigger_body_rejects_missing_property_ids() -> None: with pytest.raises(ValidationError): ModellingE2ETriggerBody.model_validate( {"portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID} ) # --------------------------------------------------------------------------- # Lodged EPC path # --------------------------------------------------------------------------- def test_lodged_epc_path_saves_epc_plan_and_marks_modelled() -> None: """When get_by_uprn returns an EPC the handler saves it, saves the plan, and marks the property as modelled — all inside one UoW per property.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_epc = MagicMock() mock_plan = _plan_mock() mock_uow = MagicMock() with ExitStack() as stack: stack.enter_context( patch("applications.modelling_e2e.handler.os.environ", _ENV) ) stack.enter_context( patch( "applications.modelling_e2e.handler._get_engine", return_value=mock_engine, ) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value.get_by_uprn.return_value = mock_epc stack.enter_context( patch("applications.modelling_e2e.handler.GeospatialS3Repository") ) stack.enter_context( patch("applications.modelling_e2e.handler.GoogleSolarApiClient") ) stack.enter_context( patch( "applications.modelling_e2e.handler._spatial_for", return_value=None ) ) stack.enter_context( patch( "applications.modelling_e2e.handler._solar_insights_for", return_value=None, ) ) stack.enter_context( patch( "applications.modelling_e2e.handler.overlays_from", return_value=[] ) ) stack.enter_context( patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") ) stack.enter_context( patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ProductPostgresRepository") ) stack.enter_context( patch("applications.modelling_e2e.handler.Session") ) stack.enter_context( patch( "applications.modelling_e2e.handler.run_modelling", return_value=mock_plan, ) ) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False # Act _call_handler(_BODY) # Assert — EPC saved (lodged path), plan saved, property marked modelled mock_uow.epc.save.assert_called_once_with( mock_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID ) mock_uow.plan.save.assert_called_once() mock_uow.property.mark_modelled.assert_called_once_with( PROPERTY_ID, has_recommendations=False ) mock_uow.commit.assert_called_once() # --------------------------------------------------------------------------- # EPC Prediction path # --------------------------------------------------------------------------- def test_prediction_path_saves_plan_without_epc_save() -> None: """When get_by_uprn returns None the handler synthesises an EPC via prediction and saves the plan — but never calls epc.save.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_plan = _plan_mock() mock_uow = MagicMock() mock_predicted_epc = MagicMock() # _predict_epc checks for a MAIN building part from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier mock_part = MagicMock() mock_part.identifier = BuildingPartIdentifier.MAIN mock_predicted_epc.sap_building_parts = [mock_part] mock_comparables = MagicMock() mock_comparables.members = [MagicMock()] # non-empty cohort with ExitStack() as stack: stack.enter_context( patch("applications.modelling_e2e.handler.os.environ", _ENV) ) stack.enter_context( patch( "applications.modelling_e2e.handler._get_engine", return_value=mock_engine, ) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value.get_by_uprn.return_value = None # no lodged EPC stack.enter_context( patch("applications.modelling_e2e.handler.GeospatialS3Repository") ) stack.enter_context( patch("applications.modelling_e2e.handler.GoogleSolarApiClient") ) stack.enter_context( patch( "applications.modelling_e2e.handler._spatial_for", return_value=None ) ) stack.enter_context( patch( "applications.modelling_e2e.handler._solar_insights_for", return_value=None, ) ) stack.enter_context( patch( "applications.modelling_e2e.handler.overlays_from", return_value=[] ) ) stack.enter_context( patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") ) # Prediction infrastructure from domain.epc_prediction.prediction_target import PredictionTargetAttributes stack.enter_context( patch( "applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader" ) ).return_value.attributes_for.return_value = PredictionTargetAttributes( property_type="2" ) stack.enter_context( patch("applications.modelling_e2e.handler.select_comparables") ).return_value = mock_comparables stack.enter_context( patch("applications.modelling_e2e.handler.EpcPrediction") ).return_value.predict.return_value = mock_predicted_epc stack.enter_context( patch( "applications.modelling_e2e.handler.EpcComparablePropertiesRepository" ) ).return_value.candidates_for.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ProductPostgresRepository") ) stack.enter_context( patch("applications.modelling_e2e.handler.Session") ) stack.enter_context( patch( "applications.modelling_e2e.handler.run_modelling", return_value=mock_plan, ) ) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False # Act _call_handler(_BODY) # Assert — epc.save NOT called (no lodged cert), plan IS saved mock_uow.epc.save.assert_not_called() mock_uow.plan.save.assert_called_once() mock_uow.commit.assert_called_once() # --------------------------------------------------------------------------- # Prediction gate-out (empty cohort) # --------------------------------------------------------------------------- def test_empty_cohort_gates_property_out_and_raises() -> None: """When candidates_for returns an empty list the property cannot be predicted; the handler records it as an error and raises RuntimeError.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) empty_comparables = MagicMock() empty_comparables.members = [] # empty cohort → gate-out with ExitStack() as stack: stack.enter_context( patch("applications.modelling_e2e.handler.os.environ", _ENV) ) stack.enter_context( patch( "applications.modelling_e2e.handler._get_engine", return_value=mock_engine, ) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value.get_by_uprn.return_value = None stack.enter_context( patch("applications.modelling_e2e.handler.GeospatialS3Repository") ) stack.enter_context( patch("applications.modelling_e2e.handler.GoogleSolarApiClient") ) stack.enter_context( patch( "applications.modelling_e2e.handler._spatial_for", return_value=None ) ) stack.enter_context( patch( "applications.modelling_e2e.handler.overlays_from", return_value=[] ) ) stack.enter_context( patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") ) from domain.epc_prediction.prediction_target import PredictionTargetAttributes stack.enter_context( patch( "applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader" ) ).return_value.attributes_for.return_value = PredictionTargetAttributes( property_type="2" ) stack.enter_context( patch("applications.modelling_e2e.handler.select_comparables") ).return_value = empty_comparables stack.enter_context( patch("applications.modelling_e2e.handler.EpcPrediction") ) stack.enter_context( patch( "applications.modelling_e2e.handler.EpcComparablePropertiesRepository" ) ).return_value.candidates_for.return_value = [] stack.enter_context( patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ProductPostgresRepository") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) # Act with pytest.raises(RuntimeError, match=str(PROPERTY_ID)): _call_handler(_BODY) # UoW never entered — the property errored before the write block MockUoW.return_value.__enter__.assert_not_called() # --------------------------------------------------------------------------- # Partial batch failure # --------------------------------------------------------------------------- def test_partial_batch_failure_raises_runtime_error_listing_failed_ids() -> None: """Two properties: property 1 succeeds, property 2 raises during modelling. Handler raises RuntimeError naming only the failed property; property 1's UoW was committed.""" # Arrange pid1, pid2 = 111, 222 mock_engine = _engine_mock([pid1, pid2], [1001, 1002], [POSTCODE, POSTCODE]) mock_plan = _plan_mock() mock_uow = MagicMock() def _run_modelling_side_effect(*args: Any, **kwargs: Any) -> Any: # Fail on second call (pid2) if not hasattr(_run_modelling_side_effect, "_calls"): _run_modelling_side_effect._calls = 0 # type: ignore[attr-defined] _run_modelling_side_effect._calls += 1 # type: ignore[attr-defined] if _run_modelling_side_effect._calls == 2: # type: ignore[attr-defined] raise ValueError("modelling exploded") return mock_plan with ExitStack() as stack: stack.enter_context( patch("applications.modelling_e2e.handler.os.environ", _ENV) ) stack.enter_context( patch( "applications.modelling_e2e.handler._get_engine", return_value=mock_engine, ) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value.get_by_uprn.return_value = MagicMock() # lodged EPC stack.enter_context( patch("applications.modelling_e2e.handler.GeospatialS3Repository") ) stack.enter_context( patch("applications.modelling_e2e.handler.GoogleSolarApiClient") ) stack.enter_context( patch( "applications.modelling_e2e.handler._spatial_for", return_value=None ) ) stack.enter_context( patch( "applications.modelling_e2e.handler._solar_insights_for", return_value=None, ) ) stack.enter_context( patch( "applications.modelling_e2e.handler.overlays_from", return_value=[] ) ) stack.enter_context( patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") ) stack.enter_context( patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ProductPostgresRepository") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch( "applications.modelling_e2e.handler.run_modelling", side_effect=_run_modelling_side_effect, ) ) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False # Act with pytest.raises(RuntimeError, match=str(pid2)): _call_handler( { "property_ids": [pid1, pid2], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, "no_solar": True, "dry_run": False, } ) # Property 1 succeeded — its UoW was committed mock_uow.commit.assert_called_once() # --------------------------------------------------------------------------- # Cohort cache hit # --------------------------------------------------------------------------- def test_cohort_cache_prevents_duplicate_candidates_for_calls() -> None: """Two properties sharing a postcode: candidates_for is fetched once and cached — EpcComparablePropertiesRepository.candidates_for called once.""" # Arrange pid1, pid2 = 301, 302 mock_engine = _engine_mock( [pid1, pid2], [3001, 3002], [POSTCODE, POSTCODE] ) mock_plan = _plan_mock() mock_uow = MagicMock() mock_predicted_epc = MagicMock() from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier mock_part = MagicMock() mock_part.identifier = BuildingPartIdentifier.MAIN mock_predicted_epc.sap_building_parts = [mock_part] mock_comparables = MagicMock() mock_comparables.members = [MagicMock()] with ExitStack() as stack: stack.enter_context( patch("applications.modelling_e2e.handler.os.environ", _ENV) ) stack.enter_context( patch( "applications.modelling_e2e.handler._get_engine", return_value=mock_engine, ) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value.get_by_uprn.return_value = None # force prediction path stack.enter_context( patch("applications.modelling_e2e.handler.GeospatialS3Repository") ) stack.enter_context( patch("applications.modelling_e2e.handler.GoogleSolarApiClient") ) stack.enter_context( patch( "applications.modelling_e2e.handler._spatial_for", return_value=None ) ) stack.enter_context( patch( "applications.modelling_e2e.handler.overlays_from", return_value=[] ) ) stack.enter_context( patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") ) from domain.epc_prediction.prediction_target import PredictionTargetAttributes stack.enter_context( patch( "applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader" ) ).return_value.attributes_for.return_value = PredictionTargetAttributes( property_type="2" ) stack.enter_context( patch("applications.modelling_e2e.handler.select_comparables") ).return_value = mock_comparables stack.enter_context( patch("applications.modelling_e2e.handler.EpcPrediction") ).return_value.predict.return_value = mock_predicted_epc MockCandidates = stack.enter_context( patch( "applications.modelling_e2e.handler.EpcComparablePropertiesRepository" ) ) MockCandidates.return_value.candidates_for.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ProductPostgresRepository") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch( "applications.modelling_e2e.handler.run_modelling", return_value=mock_plan, ) ) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False # Act _call_handler( { "property_ids": [pid1, pid2], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, "no_solar": True, "dry_run": False, } ) # Assert — cohort fetched exactly once despite two properties MockCandidates.return_value.candidates_for.assert_called_once_with(POSTCODE) # --------------------------------------------------------------------------- # Dry-run # --------------------------------------------------------------------------- def test_dry_run_skips_all_db_writes() -> None: """dry_run=True: run_modelling executes but PostgresUnitOfWork is never entered — no DB writes occur for any property in the batch.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) with ExitStack() as stack: stack.enter_context( patch("applications.modelling_e2e.handler.os.environ", _ENV) ) stack.enter_context( patch( "applications.modelling_e2e.handler._get_engine", return_value=mock_engine, ) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value.get_by_uprn.return_value = MagicMock() stack.enter_context( patch("applications.modelling_e2e.handler.GeospatialS3Repository") ) stack.enter_context( patch("applications.modelling_e2e.handler.GoogleSolarApiClient") ) stack.enter_context( patch( "applications.modelling_e2e.handler._spatial_for", return_value=None ) ) stack.enter_context( patch( "applications.modelling_e2e.handler._solar_insights_for", return_value=None, ) ) stack.enter_context( patch( "applications.modelling_e2e.handler.overlays_from", return_value=[] ) ) stack.enter_context( patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") ) stack.enter_context( patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch("applications.modelling_e2e.handler.ProductPostgresRepository") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch( "applications.modelling_e2e.handler.run_modelling", return_value=_plan_mock(), ) ) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) # Act _call_handler({**_BODY, "dry_run": True}) # Assert — UoW never entered MockUoW.return_value.__enter__.assert_not_called()