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 # --------------------------------------------------------------------------- # 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