diff --git a/app/api/routes/zipcode.py b/app/api/routes/zipcode.py index e2d7cdb..13fb4af 100644 --- a/app/api/routes/zipcode.py +++ b/app/api/routes/zipcode.py @@ -1,4 +1,5 @@ from fastapi import APIRouter +from fastapi.exceptions import RequestValidationError from app.core.errors.base import AppError from app.services.external_apis.zipcode_api import ZipCodeAPI @@ -19,11 +20,26 @@ def lookup_address(address: str): Example: address = "1600 Amphitheatre Parkway, Mountain View, CA, US" """ + if not address.strip(): + raise RequestValidationError( + errors=[ + { + "loc": ("query", "address"), + "msg": "address must not be blank", + "input": address, + "type": "value_error", + } + ] + ) + zipcode_api = ZipCodeAPI() try: return zipcode_api.lookup_address(address) except TimeoutError as e: - raise AppError(str(e), status_code=504) + raise AppError(str(e), status_code=504, error_code="GEOCODING_TIMEOUT") + except RuntimeError as e: + # Nominatim answered with an error: the failure is upstream, not ours + raise AppError(str(e), status_code=502, error_code="GEOCODING_FAILED") except Exception as e: raise AppError(str(e), status_code=500) diff --git a/tests/test_api.py b/tests/test_api.py index 3088215..350cf0c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -635,6 +635,60 @@ def test_forecast_invalid_params_rejected(self, client, monkeypatch, overrides, assert body["validation_errors"][0]["field"] == field +# ═══════════════════════════════════════════════════════════════════════════ +# Address lookup endpoint +# ═══════════════════════════════════════════════════════════════════════════ + +class TestZipcodeEndpoints: + + LOOKUP_URL = f"{API_PREFIX}/zipcode/lookup-address" + GEOCODE = "app.services.external_apis.zipcode_api._GEOLOCATOR.geocode" + + @pytest.mark.parametrize("address", ["", " "]) + def test_lookup_blank_address_rejected(self, client, monkeypatch, address): + def fake_geocode(*args, **kwargs): + raise AssertionError("Nominatim should not be called for a blank address") + + monkeypatch.setattr(self.GEOCODE, fake_geocode) + + resp = client.get(self.LOOKUP_URL, params={"address": address}) + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert body["validation_errors"][0]["field"] == "query.address" + + def test_lookup_upstream_error_is_502(self, client, monkeypatch): + from geopy.exc import GeocoderServiceError + + def fake_geocode(*args, **kwargs): + raise GeocoderServiceError("HTTP Error 503: Service Unavailable") + + monkeypatch.setattr(self.GEOCODE, fake_geocode) + + resp = client.get(self.LOOKUP_URL, params={"address": "Paris"}) + assert resp.status_code == 502 + assert resp.json()["error_code"] == "GEOCODING_FAILED" + + def test_lookup_timeout_is_504(self, client, monkeypatch): + from geopy.exc import GeocoderTimedOut + + def fake_geocode(*args, **kwargs): + raise GeocoderTimedOut("timed out") + + monkeypatch.setattr(self.GEOCODE, fake_geocode) + + resp = client.get(self.LOOKUP_URL, params={"address": "Paris"}) + assert resp.status_code == 504 + assert resp.json()["error_code"] == "GEOCODING_TIMEOUT" + + def test_lookup_no_results(self, client, monkeypatch): + monkeypatch.setattr(self.GEOCODE, lambda *args, **kwargs: None) + + resp = client.get(self.LOOKUP_URL, params={"address": "Nowhere at all"}) + assert resp.status_code == 200 + assert resp.json() == [] + + # ═══════════════════════════════════════════════════════════════════════════ # End-to-end pipeline # ═══════════════════════════════════════════════════════════════════════════