from typing import Any, Optional from unittest.mock import MagicMock, call, patch import pytest import requests from infrastructure.http_retry import TransientHttpError from infrastructure.solar.google_solar_api_client import ( BuildingInsightsNotFoundError, GoogleSolarApiClient, ) def _http_error( status_code: int, *, headers: Optional[dict[str, str]] = None ) -> requests.exceptions.HTTPError: resp = MagicMock() resp.status_code = status_code resp.headers = headers or {} err = requests.exceptions.HTTPError(str(status_code)) err.response = resp return err def _raising_response(err: Exception) -> MagicMock: resp = MagicMock() resp.raise_for_status.side_effect = err return resp def test_get_building_insights_retries_429_then_succeeds() -> None: # Arrange — a rate-limit (429) then a success. The 429 is transient, so the # client backs off and retries rather than failing. client = GoogleSolarApiClient(api_key="test-key") payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}} ok = MagicMock() ok.json.return_value = payload ok.raise_for_status.return_value = None with patch("requests.get", side_effect=[_raising_response(_http_error(429)), ok]) as mock_get: with patch("infrastructure.http_retry.time.sleep"): # Act result = client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Assert assert result == payload assert mock_get.call_count == 2 def test_get_building_insights_honours_retry_after_on_429() -> None: # Arrange — a persistent 429 advertising Retry-After: 2s. The client sleeps # exactly that (not a jittered computed backoff) and gives up bounded. client = GoogleSolarApiClient(api_key="test-key") err = _http_error(429, headers={"Retry-After": "2"}) with patch("requests.get", return_value=_raising_response(err)) as mock_get: with patch("infrastructure.http_retry.time.sleep") as sleep: # Act / Assert — exhaustion raises a TransientHttpError, not the raw HTTPError. with pytest.raises(TransientHttpError): client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Bounded: MAX_RETRIES retries + the initial attempt; Retry-After honoured. assert mock_get.call_count == GoogleSolarApiClient.MAX_RETRIES + 1 assert call(2.0) in sleep.call_args_list def test_get_building_insights_retries_on_500() -> None: # Arrange — a 5xx is transient too; retry then succeed. client = GoogleSolarApiClient(api_key="test-key") payload: dict[str, Any] = {"solarPotential": {}} ok = MagicMock() ok.json.return_value = payload ok.raise_for_status.return_value = None with patch("requests.get", side_effect=[_raising_response(_http_error(503)), ok]) as mock_get: with patch("infrastructure.http_retry.time.sleep"): # Act result = client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Assert assert result == payload assert mock_get.call_count == 2 def test_get_building_insights_does_not_retry_a_400() -> None: # Arrange — a non-transient client error (e.g. bad request) propagates at once. client = GoogleSolarApiClient(api_key="test-key") with patch("requests.get", return_value=_raising_response(_http_error(400))) as mock_get: with patch("infrastructure.http_retry.time.sleep"): # Act / Assert with pytest.raises(requests.exceptions.HTTPError): client.get_building_insights(longitude=-0.1278, latitude=51.5074) assert mock_get.call_count == 1 # --------------------------------------------------------------------------- # Throttle: per-instance min-interval keeps the fleet under the 600 QPM cap # --------------------------------------------------------------------------- def test_throttle_spaces_consecutive_calls_by_min_interval() -> None: # Arrange — a client paced at a 4s minimum gap between its OWN Solar calls # (quota/N at the 32-wide fleet: 0.8 * 600 QPM / 32 ≈ one call / 4s). The # first call fires immediately; the second must wait out the remainder of the # interval before hitting the API, so the fleet stays under the hard 600 QPM # ceiling instead of sustaining the over-quota burst that 429'd portfolio 796. client = GoogleSolarApiClient( api_key="test-key", min_request_interval_seconds=4.0 ) ok = MagicMock() ok.json.return_value = {"solarPotential": {}} ok.raise_for_status.return_value = None with patch("requests.get", return_value=ok): with patch( "infrastructure.solar.google_solar_api_client.time.sleep" ) as sleep: # Act — two back-to-back calls with no real work between them. client.get_building_insights(longitude=-0.1278, latitude=51.5074) client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Assert — first call doesn't wait; second waits ~the full interval (no time # elapsed between them, so the remaining gap is essentially the whole 4s). assert sleep.call_count == 1 (waited,), _ = sleep.call_args assert 3.5 <= waited <= 4.0 def test_throttle_off_by_default_does_not_sleep() -> None: # Arrange — no interval configured (the interactive backend path): the client # must not pace, preserving today's behaviour. client = GoogleSolarApiClient(api_key="test-key") ok = MagicMock() ok.json.return_value = {"solarPotential": {}} ok.raise_for_status.return_value = None with patch("requests.get", return_value=ok): with patch( "infrastructure.solar.google_solar_api_client.time.sleep" ) as sleep: # Act client.get_building_insights(longitude=-0.1278, latitude=51.5074) client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Assert sleep.assert_not_called() # --------------------------------------------------------------------------- # Slice 1: Successful response returns parsed JSON # --------------------------------------------------------------------------- def test_get_building_insights_returns_parsed_json() -> None: # Arrange client = GoogleSolarApiClient(api_key="test-key") payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}} mock_response = MagicMock() mock_response.json.return_value = payload with patch("requests.get", return_value=mock_response) as mock_get: mock_response.raise_for_status.return_value = None # Act result = client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Assert assert result == payload mock_get.assert_called_once() # --------------------------------------------------------------------------- # Slice 2: Transient HTTP errors trigger retries # --------------------------------------------------------------------------- def test_get_building_insights_retries_on_transient_error() -> None: # Arrange client = GoogleSolarApiClient(api_key="test-key") payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}} transient_error = requests.exceptions.ConnectionError("timeout") transient_error.response = None # type: ignore[attr-defined] success_response = MagicMock() success_response.json.return_value = payload success_response.raise_for_status.return_value = None with patch("requests.get", side_effect=[transient_error, success_response]) as mock_get: with patch("time.sleep"): # Act result = client.get_building_insights(longitude=-0.1278, latitude=51.5074) # Assert assert result == payload assert mock_get.call_count == 2 # --------------------------------------------------------------------------- # Slice 3: 404 + entity-not-found raises BuildingInsightsNotFoundError # --------------------------------------------------------------------------- def test_get_building_insights_raises_on_entity_not_found() -> None: # Arrange client = GoogleSolarApiClient(api_key="test-key") not_found_response = MagicMock() not_found_response.status_code = 404 not_found_response.json.return_value = { "error": {"message": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR} } http_error = requests.exceptions.HTTPError("404") http_error.response = not_found_response with patch("requests.get") as mock_get: mock_get.return_value.raise_for_status.side_effect = http_error mock_get.return_value.response = not_found_response # Act / Assert with pytest.raises(BuildingInsightsNotFoundError): client.get_building_insights(longitude=-0.1278, latitude=51.5074) assert mock_get.call_count == 1 # --------------------------------------------------------------------------- # Slice 4: Retry exhaustion propagates the exception to the caller # --------------------------------------------------------------------------- def test_get_building_insights_fails_bounded_after_retry_exhaustion() -> None: # Arrange — a persistent transport failure (no response). It's transient, so # the client retries up to the bound, then surfaces a TransientHttpError for # the caller to fail the subtask on (re-triggerable) — NOT an infinite loop. client = GoogleSolarApiClient(api_key="test-key") error = requests.exceptions.ConnectionError("persistent failure") error.response = None # type: ignore[attr-defined] with patch("requests.get", side_effect=error) as mock_get: with patch("infrastructure.http_retry.time.sleep"): # Act / Assert with pytest.raises(TransientHttpError): client.get_building_insights(longitude=-0.1278, latitude=51.5074) assert mock_get.call_count == GoogleSolarApiClient.MAX_RETRIES + 1