"""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, Iterator, Optional from unittest.mock import MagicMock, call, patch from uuid import UUID, uuid4 import pytest from pydantic import ValidationError from applications.modelling_e2e.modelling_e2e_trigger_body import ( ModellingE2ETriggerBody, ) from domain.tasks.subtasks import SubTask from repositories.epc.epc_postgres_repository import EpcSaveRequest 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, "refetch_solar": False, "dry_run": False, } def _mock_orchestrator() -> MagicMock: """Mock TaskOrchestrator whose run_subtasks creates one SubTask per input and runs work for each, isolating per-item failures (mirroring the real method).""" mock = MagicMock() def _run_subtasks( parent_task_id: UUID, inputs_per_subtask: list[dict[str, Any]], work: Any, **kwargs: Any, ) -> list[Any]: results: list[Any] = [] for inputs in inputs_per_subtask: subtask = SubTask.create(task_id=parent_task_id, inputs=inputs) try: results.append(work(subtask)) except Exception: # noqa: BLE001 — siblings continue, as in real impl results.append(None) return results mock.run_subtasks.side_effect = _run_subtasks return mock def _call_handler( body: dict[str, Any], orchestrator: Optional[MagicMock] = None ) -> Any: from applications.modelling_e2e.handler import handler orch = orchestrator if orchestrator is not None else _mock_orchestrator() return handler.__wrapped__(body, None, orch, uuid4()) # type: ignore[attr-defined] def _engine_mock( property_ids: list[int], uprns: list[int], postcodes: list[str], ) -> MagicMock: """Mock engine whose connect() returns one (id, uprn, postcode) row set — the handler reads all three columns in a single query.""" mock_engine = MagicMock() mock_conn = mock_engine.connect.return_value.__enter__.return_value property_result = MagicMock() property_result.fetchall.return_value = list( zip(property_ids, uprns, postcodes) ) mock_conn.execute.return_value = property_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() h._nearby_cohort_cache.clear() @pytest.fixture(autouse=True) def _baseline_orchestrator() -> Iterator[MagicMock]: """Patch the Baseline orchestrator for every test — construction stays cheap and ``.run`` is a no-op mock the baseline tests assert against.""" with patch( "applications.modelling_e2e.handler.PropertyBaselineOrchestrator" ) as orchestrator: yield orchestrator # --------------------------------------------------------------------------- # Solar throttle: per-container call gap resolved from env (quota/N safety) # --------------------------------------------------------------------------- def test_solar_min_request_interval_falls_back_to_32_wide_default() -> None: # Arrange — env var absent: the Lambda must still self-protect at the 32-wide # gap so a missed terraform wiring can't reopen the 600 QPM over-quota storm. from applications.modelling_e2e.handler import ( _DEFAULT_SOLAR_MIN_REQUEST_INTERVAL_SECONDS, _solar_min_request_interval_seconds, ) with patch.dict("os.environ", {}, clear=True): # Act result = _solar_min_request_interval_seconds() # Assert assert result == _DEFAULT_SOLAR_MIN_REQUEST_INTERVAL_SECONDS def test_solar_min_request_interval_reads_env_when_set() -> None: # Arrange — terraform injects the computed gap (e.g. a smaller N → smaller gap). from applications.modelling_e2e.handler import ( _solar_min_request_interval_seconds, ) with patch.dict( "os.environ", {"SOLAR_MIN_REQUEST_INTERVAL_SECONDS": "2.0"}, clear=True ): # Act result = _solar_min_request_interval_seconds() # Assert assert result == 2.0 # --------------------------------------------------------------------------- # 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} ) def test_trigger_body_new_flags_default_to_true() -> None: """refetch_epc, repredict_epc, and refetch_solar all default True so that existing callers that omit them get current behaviour (live fetches).""" # Arrange body = { "property_ids": [PROPERTY_ID], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, } # Act result = ModellingE2ETriggerBody.model_validate(body) # Assert assert result.refetch_epc is True assert result.repredict_epc is True assert result.refetch_solar is True # --------------------------------------------------------------------------- # Child SubTask creation # --------------------------------------------------------------------------- def test_handler_creates_one_child_subtask_per_property_id() -> None: """Handler creates exactly N child SubTasks, one per property in the batch, each recording its property_id in inputs.""" # Arrange pid1, pid2, pid3 = 111, 222, 333 mock_engine = _engine_mock( [pid1, pid2, pid3], [1001, 1002, 1003], [POSTCODE, POSTCODE, POSTCODE] ) mock_orch = _mock_orchestrator() task_id = uuid4() 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides")) 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")) mock_uow = MagicMock() MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False # Act from applications.modelling_e2e.handler import handler handler.__wrapped__( # type: ignore[attr-defined] {"property_ids": [pid1, pid2, pid3], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False}, None, mock_orch, task_id, ) # Assert — the batch is fanned out in ONE run_subtasks call, with one input # per property (each recording its property_id) under the same parent task. mock_orch.run_subtasks.assert_called_once() args = mock_orch.run_subtasks.call_args.args assert args[0] == task_id inputs_per_subtask = args[1] assert [i["property_id"] for i in inputs_per_subtask] == [pid1, pid2, pid3] # --------------------------------------------------------------------------- # Lodged EPC path # --------------------------------------------------------------------------- def test_lodged_epc_path_saves_epc_plan_and_marks_modelled( _baseline_orchestrator: MagicMock, ) -> None: """When get_by_uprn returns an EPC the handler saves it, saves the plan, marks the property as modelled — all inside one UoW per property — and re-establishes its Baseline Performance after the plan commits.""" # 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides") ) 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_batch.assert_called_once_with( [EpcSaveRequest(mock_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="lodged")] ) mock_uow.plan.save_batch.assert_called_once() mock_uow.property.mark_modelled.assert_called_once_with( PROPERTY_ID, has_recommendations=False ) mock_uow.commit.assert_called_once() # Baseline Performance is re-established for the lodged property. _baseline_orchestrator.return_value.run.assert_called_once_with([PROPERTY_ID]) def test_skipped_cohort_certs_do_not_prevent_plan_being_saved() -> None: """Cohort certs the mapper can't consume are skipped so prediction can proceed — the property's plan is committed before the handler returns.""" from repositories.comparable_properties.epc_comparable_properties_repository import ( SkippedCohortCert, ) mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_plan = _plan_mock() mock_uow = MagicMock() skipped = [ SkippedCohortCert( certificate_number="8257-7539-1649-0633-4992", error="ValueError: RdSapSchema17_1: missing required field 'window'", ) ] 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides" ) ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch( "applications.modelling_e2e.handler.run_modelling", return_value=mock_plan, ) ) # The repo accumulated a skipped (unmappable) cohort cert during the run. stack.enter_context( patch("applications.modelling_e2e.handler.EpcComparablePropertiesRepository") ).return_value.skipped = skipped 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 — no raise even with a skipped cert _call_handler(_BODY) # Assert — plan committed despite the skipped cert mock_uow.plan.save_batch.assert_called_once() mock_uow.commit.assert_called_once() def test_skipped_cohort_certs_are_logged_and_handler_does_not_raise() -> None: """Unmappable cohort certs are logged but do not raise — the coordinator SubTask completes normally and the property plan is committed.""" from repositories.comparable_properties.epc_comparable_properties_repository import ( SkippedCohortCert, ) mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_plan = _plan_mock() mock_uow = MagicMock() skipped = [ SkippedCohortCert( certificate_number="8257-7539-1649-0633-4992", error="ValueError: RdSapSchema17_1: missing required field 'window'", ) ] 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides")) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) ) stack.enter_context( patch("applications.modelling_e2e.handler.EpcComparablePropertiesRepository") ).return_value.skipped = skipped 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 mock_logger = stack.enter_context( patch("applications.modelling_e2e.handler.logger") ) # Act — must not raise even though cohort certs were skipped _call_handler(_BODY) # Assert — plan committed; skipped cert number surfaced in a log call mock_uow.plan.save_batch.assert_called_once() mock_uow.commit.assert_called_once() logged_messages = " ".join( str(c.args) + str(c.kwargs) for c in mock_logger.info.call_args_list ) assert "8257-7539-1649-0633-4992" in logged_messages # --------------------------------------------------------------------------- # EPC Prediction path # --------------------------------------------------------------------------- def test_prediction_path_saves_predicted_epc_plan_and_baseline( _baseline_orchestrator: MagicMock, ) -> None: """When get_by_uprn returns None the handler synthesises an EPC via prediction, persists it in the predicted slot (source='predicted'), saves the plan, and re-establishes the Baseline from the predicted picture.""" # 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") ).return_value.overrides_for_many.return_value = {} # 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.catalogue_snapshot_with_off_catalogue_overrides") ) 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 — predicted EPC persisted in the predicted slot, plan saved, baseline run mock_uow.epc.save_batch.assert_called_once_with( [EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")] ) mock_uow.plan.save_batch.assert_called_once() mock_uow.commit.assert_called_once() _baseline_orchestrator.return_value.run.assert_called_once_with([PROPERTY_ID]) # --------------------------------------------------------------------------- # Prediction gate-out (empty cohort) # --------------------------------------------------------------------------- def test_empty_cohort_gates_property_out_without_saving() -> None: """When candidates_for returns an empty list the property cannot be predicted; the failure is recorded in the child SubTask and the handler returns without raising — no plan is saved.""" # 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") ).return_value.overrides_for_many.return_value = {} 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.catalogue_snapshot_with_off_catalogue_overrides") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) # Act — no raise; child SubTask is failed, handler continues _call_handler(_BODY) # UoW never entered — the property errored before the write block MockUoW.return_value.__enter__.assert_not_called() # --------------------------------------------------------------------------- # Broadened cohort — sparse own postcode falls back to nearby postcodes # --------------------------------------------------------------------------- def test_empty_own_postcode_broadens_to_nearby_and_predicts() -> None: """When the property's own postcode holds no same-type comparables, the handler broadens to the nearby-postcode cohort (candidates_near) and, finding comparables there, synthesises the EPC and saves the plan.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [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] # First select_comparables (own postcode) is empty → broaden; the second # (nearby cohort) finds comparables. empty_comparables = MagicMock() empty_comparables.members = [] found_comparables = MagicMock() found_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 # 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") ).return_value.overrides_for_many.return_value = {} 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" ) MockRepo = stack.enter_context( patch( "applications.modelling_e2e.handler.EpcComparablePropertiesRepository" ) ) MockRepo.return_value.candidates_for.return_value = [] MockRepo.return_value.candidates_near.return_value = [MagicMock()] stack.enter_context( patch( "applications.modelling_e2e.handler.select_comparables", side_effect=[empty_comparables, found_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.ScenarioPostgresRepository") ).return_value.get_many.return_value = [MagicMock()] stack.enter_context( patch( "applications.modelling_e2e.handler.catalogue_snapshot_with_off_catalogue_overrides" ) ) 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 — broadening fired, and the broadened cohort produced a saved plan # with its predicted EPC persisted in the predicted slot. MockRepo.return_value.candidates_near.assert_called_once() mock_uow.epc.save_batch.assert_called_once_with( [EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")] ) mock_uow.plan.save_batch.assert_called_once() mock_uow.commit.assert_called_once() # --------------------------------------------------------------------------- # Partial batch failure — per-property isolation # --------------------------------------------------------------------------- def test_per_property_failure_fails_child_subtask_and_siblings_continue() -> None: """Two properties: property 1 succeeds, property 2 fails during modelling. The handler does not raise; both properties are run in the one batched run_subtasks pass, and property 1's write is still committed.""" # Arrange pid1, pid2 = 111, 222 mock_engine = _engine_mock([pid1, pid2], [1001, 1002], [POSTCODE, POSTCODE]) mock_plan = _plan_mock() mock_uow = MagicMock() call_count = {"n": 0} def _run_modelling_side_effect(*args: Any, **kwargs: Any) -> Any: call_count["n"] += 1 if call_count["n"] == 2: raise ValueError("modelling exploded") return mock_plan mock_orch = _mock_orchestrator() 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides")) 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 — must not raise even though pid2 fails _call_handler( {"property_ids": [pid1, pid2], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False}, orchestrator=mock_orch, ) # Both properties run in one batched run_subtasks pass; pid1 still committed # (the failed pid2 is isolated and simply never queued for write). mock_orch.run_subtasks.assert_called_once() assert len(mock_orch.run_subtasks.call_args.args[1]) == 2 mock_uow.commit.assert_called_once() # --------------------------------------------------------------------------- # End-of-batch write — one transaction for the whole batch # --------------------------------------------------------------------------- def test_batch_persists_in_one_transaction_and_one_baseline_run( _baseline_orchestrator: MagicMock, ) -> None: """Three properties model independently but persist together: every Plan is saved, yet the write Unit of Work commits exactly once and the Baseline orchestrator runs once for the whole batch (the RDS-CPU win).""" # Arrange pid1, pid2, pid3 = 111, 222, 333 mock_engine = _engine_mock( [pid1, pid2, pid3], [1001, 1002, 1003], [POSTCODE, POSTCODE, POSTCODE] ) 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 = 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides")) 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")) MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False # Act _call_handler( {"property_ids": [pid1, pid2, pid3], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False} ) # Assert — all three Plans saved in one batch call, but a single shared transaction: mock_uow.plan.save_batch.assert_called_once() assert len(mock_uow.plan.save_batch.call_args[0][0]) == 3 assert mock_uow.property.mark_modelled.call_count == 3 mock_uow.commit.assert_called_once() # One write Unit of Work opened for the whole batch, not one per property. MockUoW.return_value.__enter__.assert_called_once() # Baseline re-established once, for every written property together. _baseline_orchestrator.return_value.run.assert_called_once_with([pid1, pid2, pid3]) # --------------------------------------------------------------------------- # 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") ).return_value.overrides_for_many.return_value = {} 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.catalogue_snapshot_with_off_catalogue_overrides") ) 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, "refetch_solar": False, "dry_run": False, } ) # Assert — cohort fetched exactly once despite two properties MockCandidates.return_value.candidates_for.assert_called_once_with(POSTCODE) # --------------------------------------------------------------------------- # refetch_epc flag # --------------------------------------------------------------------------- def test_refetch_epc_false_with_stored_epc_skips_api_call() -> None: """refetch_epc=False + stored lodged EPC present: EpcClientService.get_by_uprn is never called; the stored EPC is used and reaches run_modelling.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) stored_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) ) mock_epc_client = stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value mock_epc_client.get_by_uprn.return_value = MagicMock() # would be called if flag ignored 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) mock_run_modelling = stack.enter_context( patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) ) # Bulk-read of stored lodged EPCs returns the stored EPC for this property stack.enter_context( patch("applications.modelling_e2e.handler.EpcPostgresRepository") ).return_value.get_for_properties.return_value = {PROPERTY_ID: stored_epc} 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, "refetch_epc": False}) # Assert — API not called; stored EPC flows into run_modelling mock_epc_client.get_by_uprn.assert_not_called() mock_run_modelling.assert_called_once() # Stored EPC is NOT re-saved — it was read from DB unchanged (PR #1353) mock_uow.epc.save_batch.assert_not_called() def test_refetch_epc_false_without_stored_epc_skips_api_and_goes_to_prediction() -> None: """refetch_epc=False + no stored lodged EPC: the EPC API is not called; the property is treated as EPC-less and prediction runs instead.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [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) ) mock_epc_client = stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value mock_epc_client.get_by_uprn.return_value = MagicMock() # would be called if flag ignored 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") ).return_value.overrides_for_many.return_value = {} 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.catalogue_snapshot_with_off_catalogue_overrides") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) ) # No stored lodged EPC stack.enter_context( patch("applications.modelling_e2e.handler.EpcPostgresRepository") ).return_value.get_for_properties.return_value = {} 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, "refetch_epc": False}) # Assert — API was NOT called; prediction ran and its output was persisted mock_epc_client.get_by_uprn.assert_not_called() mock_uow.epc.save_batch.assert_called_once_with( [EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")] ) def test_refetch_epc_true_always_calls_api_even_if_stored_epc_exists() -> None: """refetch_epc=True (default): EpcClientService.get_by_uprn is called even when a stored lodged EPC exists — existing behaviour is preserved.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) live_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) ) mock_epc_client = stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value mock_epc_client.get_by_uprn.return_value = live_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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides") ) 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 — default refetch_epc=True (not setting the flag at all) _call_handler(_BODY) # Assert — API was called regardless mock_epc_client.get_by_uprn.assert_called_once_with(UPRN) # --------------------------------------------------------------------------- # Dry-run # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # repredict_epc flag # --------------------------------------------------------------------------- def test_repredict_epc_false_with_stored_predicted_epc_skips_prediction() -> None: """repredict_epc=False + stored predicted EPC present: EpcPrediction.predict is not called; the stored predicted EPC reaches run_modelling.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) stored_predicted = MagicMock() from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier mock_part = MagicMock() mock_part.identifier = BuildingPartIdentifier.MAIN stored_predicted.sap_building_parts = [mock_part] 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 = None # no lodged EPC → 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._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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) ) mock_predictor = stack.enter_context( patch("applications.modelling_e2e.handler.EpcPrediction") ).return_value # Stored predicted EPC is present stack.enter_context( patch("applications.modelling_e2e.handler.EpcPostgresRepository") ).return_value.get_predicted_for_properties.return_value = { PROPERTY_ID: stored_predicted } 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, "repredict_epc": False}) # Assert — EpcPrediction.predict never called; stored predicted EPC NOT re-saved (PR #1353) mock_predictor.predict.assert_not_called() mock_uow.epc.save_batch.assert_not_called() def test_repredict_epc_false_without_stored_predicted_epc_falls_back_to_live_prediction() -> None: """repredict_epc=False + no stored predicted EPC: handler falls back to live prediction so the property is not silently skipped.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [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 # 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") ).return_value.overrides_for_many.return_value = {} 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 mock_predictor = stack.enter_context( patch("applications.modelling_e2e.handler.EpcPrediction") ).return_value mock_predictor.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.catalogue_snapshot_with_off_catalogue_overrides") ) stack.enter_context(patch("applications.modelling_e2e.handler.Session")) stack.enter_context( patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) ) # No stored predicted EPC stack.enter_context( patch("applications.modelling_e2e.handler.EpcPostgresRepository") ).return_value.get_predicted_for_properties.return_value = {} 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, "repredict_epc": False}) # Assert — live prediction was used as fallback mock_predictor.predict.assert_called_once() mock_uow.epc.save_batch.assert_called_once_with( [EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")] ) # --------------------------------------------------------------------------- # 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") ).return_value.overrides_for_many.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.catalogue_snapshot_with_off_catalogue_overrides") ) 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()