diff --git a/app/api/routes/weather.py b/app/api/routes/weather.py index 1173599..f1e7980 100644 --- a/app/api/routes/weather.py +++ b/app/api/routes/weather.py @@ -1,4 +1,7 @@ -from fastapi import APIRouter +from datetime import date + +from fastapi import APIRouter, Query +from fastapi.exceptions import RequestValidationError from app.core.errors.base import AppError from app.services.external_apis.weather_api import WeatherAPI @@ -21,10 +24,10 @@ @router.get("/forecast") def get_weather_forecast( - latitude: float, - longitude: float, - start_date: str, - end_date: str, + latitude: float = Query(..., ge=-90, le=90), + longitude: float = Query(..., ge=-180, le=180), + start_date: date = Query(...), + end_date: date = Query(...), fields: str | None = None, ): """ @@ -33,10 +36,24 @@ def get_weather_forecast( Coordinates must be supplied as **decimal degrees** — not degree/minute/second notation. E.g. -122.4194, 37.7749. Dates must use the **YYYY-MM-DD** format (ISO 8601). """ + if end_date < start_date: + raise RequestValidationError( + errors=[ + { + "loc": ("query", "end_date"), + "msg": "end_date must not be before start_date", + "input": end_date.isoformat(), + "type": "value_error", + } + ] + ) + weather_api = WeatherAPI() try: requested = [f.strip() for f in fields.split(",") if f.strip()] if fields else [] - weather_data = weather_api.get_weather(latitude, longitude, start_date, end_date, hourly_fields=requested) + weather_data = weather_api.get_weather( + latitude, longitude, start_date.isoformat(), end_date.isoformat(), hourly_fields=requested + ) return weather_data except Exception as e: raise AppError(str(e), status_code=500) \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index 7f36dcc..3088215 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,6 +6,8 @@ from uuid import uuid4 +import pytest + from sqlmodel import select from app.api.schemas.enums import InputStatus, InputType @@ -577,6 +579,62 @@ def fake_whisper(*args, **kwargs): assert body["detail"]["received_size_bytes"] == 8 +# ═══════════════════════════════════════════════════════════════════════════ +# Weather endpoint +# ═══════════════════════════════════════════════════════════════════════════ + +class TestWeatherEndpoints: + + WEATHER_URL = f"{API_PREFIX}/weather/forecast" + VALID = {"latitude": 37.77, "longitude": -122.42, "start_date": "2026-10-19", "end_date": "2026-10-20"} + + def _no_upstream_call(self, monkeypatch): + def fake_get_weather(*args, **kwargs): + raise AssertionError("Open-Meteo should not be called for invalid input") + + monkeypatch.setattr( + "app.api.routes.weather.WeatherAPI.get_weather", fake_get_weather + ) + + def test_forecast_valid_request(self, client, monkeypatch): + captured = {} + + def fake_get_weather(self, latitude, longitude, start_date, end_date, hourly_fields=None): + captured.update(latitude=latitude, longitude=longitude, start_date=start_date, end_date=end_date) + return {"hourly": {}} + + monkeypatch.setattr("app.api.routes.weather.WeatherAPI.get_weather", fake_get_weather) + + resp = client.get(self.WEATHER_URL, params=self.VALID) + assert resp.status_code == 200 + assert captured == { + "latitude": 37.77, + "longitude": -122.42, + "start_date": "2026-10-19", + "end_date": "2026-10-20", + } + + @pytest.mark.parametrize( + "overrides, field", + [ + ({"latitude": 200}, "query.latitude"), + ({"latitude": -90.5}, "query.latitude"), + ({"longitude": 181}, "query.longitude"), + ({"start_date": "tomorrow"}, "query.start_date"), + ({"end_date": "2026-13-01"}, "query.end_date"), + ({"start_date": "2026-10-22", "end_date": "2026-10-19"}, "query.end_date"), + ], + ) + def test_forecast_invalid_params_rejected(self, client, monkeypatch, overrides, field): + self._no_upstream_call(monkeypatch) + + resp = client.get(self.WEATHER_URL, params={**self.VALID, **overrides}) + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert body["validation_errors"][0]["field"] == field + + # ═══════════════════════════════════════════════════════════════════════════ # End-to-end pipeline # ═══════════════════════════════════════════════════════════════════════════