"""PostcodesIoClient — coordinate/seed postcode → the real unit postcodes near it, via postcodes.io's keyless nearest endpoint. Failure degrades to the seed alone so broadening never breaks prediction.""" from __future__ import annotations from typing import Any, Iterator, Optional from unittest.mock import MagicMock, patch import httpx import pytest from domain.geospatial.coordinates import Coordinates from infrastructure.postcodes_io.postcodes_io_client import PostcodesIoClient _MODULE = "infrastructure.postcodes_io.postcodes_io_client" @pytest.fixture(autouse=True) def _no_sleep() -> Iterator[MagicMock]: """Never actually sleep during backoff (the shared retry owns the sleep) — just record the calls.""" with patch("infrastructure.http_retry.time.sleep") as sleep: yield sleep def _response( payload: Any, *, status_code: int = 200, headers: Optional[dict[str, str]] = None, ) -> MagicMock: resp = MagicMock() resp.status_code = status_code resp.is_success = 200 <= status_code < 300 resp.headers = headers if headers is not None else {} resp.json.return_value = payload return resp def _nearest_payload(postcodes: list[str]) -> dict[str, Any]: return {"result": [{"postcode": p} for p in postcodes]} def test_nearby_with_coordinates_skips_the_centroid_lookup() -> None: """When the target's own coordinates are passed, only the radius search is issued — no postcode→centroid round-trip — and the seed leads the result.""" # Arrange client = PostcodesIoClient(radius_m=500, limit=10) coords = Coordinates(longitude=0.1, latitude=51.3) with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.return_value = _response( _nearest_payload(["BR6 6BS", "BR6 6BU", "BR6 6NX"]) ) # Act result = client.nearby("BR6 6BS", coords) # Assert — one call (the radius search), seed first, neighbours follow assert result == ["BR6 6BS", "BR6 6BU", "BR6 6NX"] assert mock_get.call_count == 1 _, kwargs = mock_get.call_args assert kwargs["params"]["lat"] == 51.3 assert kwargs["params"]["lon"] == 0.1 def test_nearby_resolves_the_seed_centroid_when_no_coordinates_given() -> None: """Without coordinates the client first resolves the seed's own centroid via postcodes.io, then runs the radius search from it.""" # Arrange client = PostcodesIoClient() centroid = {"result": {"latitude": 51.3, "longitude": 0.1}} with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.side_effect = [ _response(centroid), _response(_nearest_payload(["BR6 6BS", "BR6 6BU"])), ] # Act result = client.nearby("BR6 6BS") # Assert — two calls: centroid then radius assert result == ["BR6 6BS", "BR6 6BU"] assert mock_get.call_count == 2 def test_nearby_dedupes_the_seed_and_caps_at_limit() -> None: """The seed always leads exactly once even when the radius search echoes it, and the result is capped at ``limit``.""" # Arrange client = PostcodesIoClient(limit=3) coords = Coordinates(longitude=0.1, latitude=51.3) with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.return_value = _response( _nearest_payload(["BR6 6BS", "BR6 6BU", "BR6 6NX", "BR6 6AA"]) ) # Act result = client.nearby("BR6 6BS", coords) # Assert assert result == ["BR6 6BS", "BR6 6BU", "BR6 6NX"] assert result.count("BR6 6BS") == 1 def test_nearby_returns_just_the_seed_after_exhausting_retries( _no_sleep: MagicMock, ) -> None: """A persistent network error is retried, then degrades to broadening-off: only the seed comes back, and the retries were actually attempted.""" # Arrange client = PostcodesIoClient() coords = Coordinates(longitude=0.1, latitude=51.3) with patch( f"{_MODULE}.httpx.get", side_effect=httpx.ConnectError("down") ) as mock_get: # Act result = client.nearby("BR6 6BS", coords) # Assert — one initial try + MAX_RETRIES, sleeping between each. assert result == ["BR6 6BS"] assert mock_get.call_count == client.MAX_RETRIES + 1 assert _no_sleep.call_count == client.MAX_RETRIES def test_nearby_retries_a_transport_error_then_succeeds(_no_sleep: MagicMock) -> None: """A transient transport error is retried, and the subsequent success is returned in full.""" # Arrange client = PostcodesIoClient() coords = Coordinates(longitude=0.1, latitude=51.3) with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.side_effect = [ httpx.ReadTimeout("slow"), _response(_nearest_payload(["BR6 6BS", "BR6 6BU"])), ] # Act result = client.nearby("BR6 6BS", coords) # Assert assert result == ["BR6 6BS", "BR6 6BU"] assert mock_get.call_count == 2 assert _no_sleep.call_count == 1 def test_nearby_retries_a_429_honouring_retry_after(_no_sleep: MagicMock) -> None: """A 429 is retried, and the server's Retry-After drives the backoff delay.""" # Arrange client = PostcodesIoClient() coords = Coordinates(longitude=0.1, latitude=51.3) with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.side_effect = [ _response(None, status_code=429, headers={"Retry-After": "2"}), _response(_nearest_payload(["BR6 6BS", "BR6 6BU"])), ] # Act result = client.nearby("BR6 6BS", coords) # Assert — succeeded on the retry, having slept the advertised 2 seconds. assert result == ["BR6 6BS", "BR6 6BU"] assert mock_get.call_count == 2 _no_sleep.assert_called_once_with(2.0) def test_nearby_retries_a_server_error_then_succeeds(_no_sleep: MagicMock) -> None: """A 5xx is treated as transient and retried.""" # Arrange client = PostcodesIoClient() coords = Coordinates(longitude=0.1, latitude=51.3) with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.side_effect = [ _response(None, status_code=503), _response(_nearest_payload(["BR6 6BS"])), ] # Act result = client.nearby("BR6 6BS", coords) # Assert assert result == ["BR6 6BS"] assert mock_get.call_count == 2 def test_nearby_returns_just_the_seed_when_centroid_unresolvable() -> None: """An unknown seed (no coordinates, centroid lookup fails) yields the seed alone rather than raising.""" # Arrange client = PostcodesIoClient() with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.return_value = _response(None, status_code=404) # Act result: list[str] = client.nearby("ZZ99 9ZZ") # Assert — a 404 is non-transient, so no retry was attempted. assert result == ["ZZ99 9ZZ"] assert mock_get.call_count == 1 def test_nearby_tolerates_a_null_nearest_result() -> None: """postcodes.io returns ``result: null`` when a point has no neighbours; the client treats that as an empty neighbour set (seed only).""" # Arrange client = PostcodesIoClient() coords: Optional[Coordinates] = Coordinates(longitude=0.1, latitude=51.3) with patch(f"{_MODULE}.httpx.get") as mock_get: mock_get.return_value = _response({"result": None}) # Act result = client.nearby("BR6 6BS", coords) # Assert assert result == ["BR6 6BS"]