From d3b687c0c66eb5bfcab59f0a608e40eca39a4ae9 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 16 Jan 2026 13:35:56 +0200 Subject: [PATCH 01/10] Explicit asyncio loop scope --- pytest.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pytest.ini b/pytest.ini index c74dd1e..14b8a4b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,8 @@ [pytest] # Pytest configuration for OpenAgri-WeatherService +# PytestDeprecationWarning +asyncio_default_fixture_loop_scope = function # Test discovery patterns python_files = test_*.py python_classes = Test* @@ -49,3 +51,4 @@ exclude_lines = if TYPE_CHECKING: @abstractmethod + From d016ae13b59a02cb1713234c5fcec45db5e1acce Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Thu, 19 Mar 2026 10:36:21 +0200 Subject: [PATCH 02/10] move fixtures inside configuration file to be shared across all tests --- tests/conftest.py | 108 +++++++++++++++++++++++ tests/core/test_models.py | 1 - tests/core/test_utils.py | 1 - tests/fixtures.py | 110 ------------------------ tests/test_external_services_mocking.py | 1 - tests/test_openweathermap.py | 1 - 6 files changed, 108 insertions(+), 114 deletions(-) delete mode 100644 tests/fixtures.py diff --git a/tests/conftest.py b/tests/conftest.py index f99400e..0b52a1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,23 @@ """ import pytest +from datetime import datetime, timedelta +from unittest.mock import AsyncMock +import jwt +from beanie import Document, init_beanie +from httpx import AsyncClient +from mongomock_motor import AsyncMongoMockClient + +import src.utils as utils +from src.api.api import data_router +from src.core import config +from src.core.dao import Dao +from src.external_services.openweathermap import OpenWeatherMap +from src.main import create_app +from src.models.uav import UAVModel +from src.schemas.uav import (FlightForecastListResponse, + FlightStatusForecastResponse) # Configure pytest-asyncio @pytest.fixture @@ -13,6 +29,98 @@ def anyio_backend(): return "asyncio" + +@pytest.fixture +async def openweathermap_srv(): + + dao_mock = AsyncMock() + owm_srv = OpenWeatherMap() + owm_srv.setup_dao(dao_mock) + yield owm_srv + + +@pytest.fixture +async def app(openweathermap_srv): + _app = create_app() + _app.include_router(data_router) + + # Mock the MongoDB client + mongodb_client = AsyncMongoMockClient() + mongodb = mongodb_client["test_database"] + _app.dao = Dao(mongodb_client) + await init_beanie( + database=mongodb, + document_models=utils.load_classes("**/models/**.py", (Document,)), + ) + + _app.weather_app = openweathermap_srv + + yield _app + + +@pytest.fixture +def test_jwt_token(): + """Generate a valid JWT token for testing.""" + payload = { + "sub": "test_user", + "exp": datetime.utcnow() + timedelta(hours=1), # Token valid for 1 hour + "roles": ["user"], # Include any necessary claims + } + token = jwt.encode(payload, config.KEY, algorithm=config.ALGORITHM) + return token + + +@pytest.fixture +async def async_client(app): + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + +@pytest.fixture +def mock_uav(app): + return UAVModel( + model="DJI", + manufacturer="MANU", + max_wind_speed=10.0, + min_operating_temp=-10.0, + max_operating_temp=40.0, + precipitation_tolerance=42.0, + ) + + +@pytest.fixture +def mock_weather_data(app): + now = datetime.utcnow() + return { + "cod": "200", + "list": [ + { + "dt_txt": (now + timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S"), + "main": {"temp": 10, "feels_like": 8}, + "wind": {"speed": 5}, + "pop": 0.1, + "weather": [{"description": "clear sky"}], + } + ], + } + + +@pytest.fixture +def mock_flight_response(): + """Mock flight forecast response""" + return FlightForecastListResponse( + forecasts=[ + FlightStatusForecastResponse( + timestamp=datetime.utcnow().isoformat(), + uavmodel="DJI", + status="SAFE", + weather_source="OpenWeatherMap", + weather_params={"temp": 10, "wind": 5, "precipitation": 0.1}, + location={"type": "Point", "coordinates": [52.0, 13.0]}, + ) + ] + ) + # Configuration for pytest markers def pytest_configure(config): """Configure custom pytest markers""" diff --git a/tests/core/test_models.py b/tests/core/test_models.py index 9e8009b..66cf969 100644 --- a/tests/core/test_models.py +++ b/tests/core/test_models.py @@ -4,7 +4,6 @@ from src.models.point import Point from src.models.prediction import Prediction from src.models.weather_data import WeatherData -from tests.fixtures import * class TestModels: diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 6fe43ba..869f736 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,7 +1,6 @@ import pytest from src.utils import calculate_thi -from tests.fixtures import * class TestUtils: diff --git a/tests/fixtures.py b/tests/fixtures.py deleted file mode 100644 index dded796..0000000 --- a/tests/fixtures.py +++ /dev/null @@ -1,110 +0,0 @@ -from datetime import datetime, timedelta -from unittest.mock import AsyncMock - -import jwt -import pytest -from beanie import Document, init_beanie -from httpx import AsyncClient -from mongomock_motor import AsyncMongoMockClient - -import src.utils as utils -from src.api.api import data_router -from src.core import config -from src.core.dao import Dao -from src.external_services.openweathermap import OpenWeatherMap -from src.main import create_app -from src.models.uav import UAVModel -from src.schemas.uav import (FlightForecastListResponse, - FlightStatusForecastResponse) - - -@pytest.fixture -async def openweathermap_srv(): - - dao_mock = AsyncMock() - owm_srv = OpenWeatherMap() - owm_srv.setup_dao(dao_mock) - yield owm_srv - - -@pytest.fixture -async def app(openweathermap_srv): - _app = create_app() - _app.include_router(data_router) - - # Mock the MongoDB client - mongodb_client = AsyncMongoMockClient() - mongodb = mongodb_client["test_database"] - _app.dao = Dao(mongodb_client) - await init_beanie( - database=mongodb, - document_models=utils.load_classes("**/models/**.py", (Document,)), - ) - - _app.weather_app = openweathermap_srv - - yield _app - - -@pytest.fixture -def test_jwt_token(): - """Generate a valid JWT token for testing.""" - payload = { - "sub": "test_user", - "exp": datetime.utcnow() + timedelta(hours=1), # Token valid for 1 hour - "roles": ["user"], # Include any necessary claims - } - token = jwt.encode(payload, config.KEY, algorithm=config.ALGORITHM) - return token - - -@pytest.fixture -async def async_client(app): - async with AsyncClient(app=app, base_url="http://test") as ac: - yield ac - - -@pytest.fixture -def mock_uav(app): - return UAVModel( - model="DJI", - manufacturer="MANU", - max_wind_speed=10.0, - min_operating_temp=-10.0, - max_operating_temp=40.0, - precipitation_tolerance=42.0, - ) - - -@pytest.fixture -def mock_weather_data(app): - now = datetime.utcnow() - return { - "cod": "200", - "list": [ - { - "dt_txt": (now + timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S"), - "main": {"temp": 10, "feels_like": 8}, - "wind": {"speed": 5}, - "pop": 0.1, - "weather": [{"description": "clear sky"}], - } - ], - } - - -@pytest.fixture -def mock_flight_response(): - """Mock flight forecast response""" - return FlightForecastListResponse( - forecasts=[ - FlightStatusForecastResponse( - timestamp=datetime.utcnow().isoformat(), - uavmodel="DJI", - status="SAFE", - weather_source="OpenWeatherMap", - weather_params={"temp": 10, "wind": 5, "precipitation": 0.1}, - location={"type": "Point", "coordinates": [52.0, 13.0]}, - ) - ] - ) diff --git a/tests/test_external_services_mocking.py b/tests/test_external_services_mocking.py index d7e4ed6..bff88ab 100644 --- a/tests/test_external_services_mocking.py +++ b/tests/test_external_services_mocking.py @@ -11,7 +11,6 @@ from src.external_services.openmeteo import OpenMeteoClient from src.external_services.openweathermap import SourceError -from tests.fixtures import openweathermap_srv class TestOpenWeatherMapApi: diff --git a/tests/test_openweathermap.py b/tests/test_openweathermap.py index 441e868..8a0f54c 100644 --- a/tests/test_openweathermap.py +++ b/tests/test_openweathermap.py @@ -6,7 +6,6 @@ from src.external_services import openweathermap from src.models.point import Point from src.models.prediction import Prediction -from tests.fixtures import openweathermap_srv class TestOpenWeatherMap: From c40841dde21bec428cbfd76e060055796efb1675 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Thu, 19 Mar 2026 10:57:16 +0200 Subject: [PATCH 03/10] rename test_jwt_token to jwt_token --- tests/conftest.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0b52a1a..489d8e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,13 @@ async def app(openweathermap_srv): @pytest.fixture -def test_jwt_token(): +async def async_client(app): + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + +@pytest.fixture +def jwt_token(): """Generate a valid JWT token for testing.""" payload = { "sub": "test_user", @@ -69,13 +75,6 @@ def test_jwt_token(): token = jwt.encode(payload, config.KEY, algorithm=config.ALGORITHM) return token - -@pytest.fixture -async def async_client(app): - async with AsyncClient(app=app, base_url="http://test") as ac: - yield ac - - @pytest.fixture def mock_uav(app): return UAVModel( From 25296e9467a9d48436c3ed6018d43bc856b03f20 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 11:26:26 +0200 Subject: [PATCH 04/10] move weather_data mock fixture in conftest --- tests/conftest.py | 39 ++++++++++++++++++------- tests/test_external_services_mocking.py | 27 ----------------- 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 489d8e0..ec416db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,23 +87,40 @@ def mock_uav(app): ) + + @pytest.fixture -def mock_weather_data(app): - now = datetime.utcnow() +def mock_owm_current_weather_response(): + """ + Moved here from TestOpenWeatherMapApi so that it can be shared + """ return { - "cod": "200", - "list": [ - { - "dt_txt": (now + timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S"), - "main": {"temp": 10, "feels_like": 8}, - "wind": {"speed": 5}, - "pop": 0.1, - "weather": [{"description": "clear sky"}], - } + "coord": {"lon": -74.0060, "lat": 40.7128}, + "weather": [ + {"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"} ], + "main": { + "temp": 25.5, + "feels_like": 26.2, + "temp_min": 23.0, + "temp_max": 28.0, + "pressure": 1013, + "humidity": 60, + "sea_level": 1013, + "grnd_level": 1010, + }, + "wind": {"speed": 5.2, "deg": 180, "gust": 7.8}, + "clouds": {"all": 0}, + "dt": 1640995200, + "sys": {"country": "US"}, + "timezone": -18000, + "id": 5128581, + "name": "New York", + "cod": 200, } +# not used anywhere @pytest.fixture def mock_flight_response(): """Mock flight forecast response""" diff --git a/tests/test_external_services_mocking.py b/tests/test_external_services_mocking.py index bff88ab..cfa0943 100644 --- a/tests/test_external_services_mocking.py +++ b/tests/test_external_services_mocking.py @@ -57,33 +57,6 @@ def mock_owm_5day_forecast_response(self): }, } - @pytest.fixture - def mock_owm_current_weather_response(self): - return { - "coord": {"lon": -74.0060, "lat": 40.7128}, - "weather": [ - {"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"} - ], - "main": { - "temp": 25.5, - "feels_like": 26.2, - "temp_min": 23.0, - "temp_max": 28.0, - "pressure": 1013, - "humidity": 60, - "sea_level": 1013, - "grnd_level": 1010, - }, - "wind": {"speed": 5.2, "deg": 180, "gust": 7.8}, - "clouds": {"all": 0}, - "dt": 1640995200, - "sys": {"country": "US"}, - "timezone": -18000, - "id": 5128581, - "name": "New York", - "cod": 200, - } - @pytest.mark.anyio async def test_get_weather_forecast5days_successful_api_response( self, openweathermap_srv, mock_owm_5day_forecast_response From a93af8be40468a56c7904f6839c7f7614d9069b1 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 11:27:03 +0200 Subject: [PATCH 05/10] get_thi unitests --- tests/test_openweathermap.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_openweathermap.py b/tests/test_openweathermap.py index 8a0f54c..96a24ed 100644 --- a/tests/test_openweathermap.py +++ b/tests/test_openweathermap.py @@ -4,8 +4,8 @@ import pytest from src.external_services import openweathermap -from src.models.point import Point from src.models.prediction import Prediction +from src.models.point import Point class TestOpenWeatherMap: @@ -54,3 +54,33 @@ async def test_get_weather_forecast5days_ld_catches_exception( lat, lon = (42.424242, 24.242424) with pytest.raises(Exception): await openweathermap_srv.get_weather_forecast5days_ld(lat, lon) + + @pytest.mark.anyio + async def test_get_thi_ocm_set_to_false_saves_and_returns_weather_data( + self, + openweathermap_srv, + ): + mock_weather_data = MagicMock() + openweathermap_srv.save_weather_data_thi = AsyncMock(return_value=mock_weather_data) + + result = await openweathermap_srv.get_thi(40.7128, -74.0060, ocsm=False) + + openweathermap_srv.save_weather_data_thi.assert_called_once_with(40.7128, -74.0060) + assert result == mock_weather_data + + @pytest.mark.anyio + async def test_get_thi_ocm_set_to_true_saves_and_returns_weather_data_jsonld( + self, + openweathermap_srv, + ): + mock_weather_data = MagicMock() + openweathermap_srv.save_weather_data_thi = AsyncMock(return_value=mock_weather_data) + mock = MagicMock(return_value={"@context": {}}) + openweathermap.InteroperabilitySchema.weather_data_to_jsonld = mock + result = await openweathermap_srv.get_thi(40.7128, -74.0060, ocsm=True) + + openweathermap.InteroperabilitySchema.weather_data_to_jsonld.assert_called_once_with(mock_weather_data) + assert result == {"@context": {}} + + + From 52769789ea0707e352d35dc545fddc627a0eb095 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 13:06:52 +0200 Subject: [PATCH 06/10] test data routes --- tests/api/api_v1/test_history.py | 5 + tests/api/api_v1/test_locations.py | 0 tests/api/data/test_data_routes.py | 363 +++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 tests/api/api_v1/test_history.py create mode 100644 tests/api/api_v1/test_locations.py create mode 100644 tests/api/data/test_data_routes.py diff --git a/tests/api/api_v1/test_history.py b/tests/api/api_v1/test_history.py new file mode 100644 index 0000000..1e7d08c --- /dev/null +++ b/tests/api/api_v1/test_history.py @@ -0,0 +1,5 @@ +import pytest + + +class TestHistory: + pass \ No newline at end of file diff --git a/tests/api/api_v1/test_locations.py b/tests/api/api_v1/test_locations.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/data/test_data_routes.py b/tests/api/data/test_data_routes.py new file mode 100644 index 0000000..61227f2 --- /dev/null +++ b/tests/api/data/test_data_routes.py @@ -0,0 +1,363 @@ +import pytest +from unittest.mock import AsyncMock +import uuid +from fastapi import HTTPException + + +BASE_PARAMS = {"lat": 40.7128, "lon": -74.0060} + +MOCK_LOCATION = { + "type": "Point", + "coordinates": [40.7128, -74.0060] +} + +MOCK_SPATIAL_ENTITY = { + "location": MOCK_LOCATION +} + +class TestDataRoutes: + @pytest.fixture + def auth_headers(self, jwt_token): + return {"Authorization": f"Bearer {jwt_token}"} + + @pytest.fixture + def mock_weather_data_out(self): + return { + "id": "bad6cd67-638f-42d8-82b8-d4d191174dd6", + "spatial_entity": { + "location": { + "type": "Point", + "coordinates": [40.7128, -74.0060] + } + }, + "data": { + "weather": [{"description": "clear sky"}], + "main": {"temp": 25.5, "humidity": 60, "pressure": 1013}, + "wind": {"speed": 5.2}, + "dt": 1640995200 + } + } + @pytest.fixture + def mock_thi_data_out(self): + return { + "id": str(uuid.uuid4()), + "spatial_entity": MOCK_SPATIAL_ENTITY, + "thi": 72.5 + } + @pytest.fixture + def mock_jsonld_response(self): + return { + "@context": [ + "https://www.w3.org/ns/sosa/", + {"@vocab": "https://example.org/"} + ], + "@graph": [ + { + "@id": "urn:example:location:1", + "@type": ["FeatureOfInterest", "Point"], + "lon": -74.0060, + "lat": 40.7128 + } + ] + } + @pytest.mark.anyio + async def test_get_weather_forecast5days_returns_200( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_weather_forecast5days = AsyncMock(return_value=[]) + + response = await async_client.get( + "/api/data/forecast5/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + openweathermap_srv.get_weather_forecast5days.assert_called_once_with( + BASE_PARAMS["lat"], BASE_PARAMS["lon"] + ) + + @pytest.mark.anyio + async def test_get_weather_forecast5days_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_weather_forecast5days = AsyncMock( + side_effect=Exception("service error") + ) + + response = await async_client.get( + "/api/data/forecast5/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_weather_forecast5days_returns_403_without_auth( + self, async_client + ): + response = await async_client.get( + "/api/data/forecast5/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_weather_forecast5days_ld_returns_200( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_weather_forecast5days_ld = AsyncMock( + return_value={"@context": {}} + ) + + response = await async_client.get( + "/api/linkeddata/forecast5/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + + @pytest.mark.anyio + async def test_get_weather_forecast5days_ld_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_weather_forecast5days_ld = AsyncMock( + side_effect=Exception("service error") + ) + + response = await async_client.get( + "/api/linkeddata/forecast5/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_weather_forecast5days_ld_returns_403_without_auth( + self, async_client + ): + response = await async_client.get( + "/api/linkeddata/forecast5/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_weather_returns_200( + self, async_client, openweathermap_srv, auth_headers, mock_weather_data_out + ): + openweathermap_srv.get_weather = AsyncMock(return_value=mock_weather_data_out) + + response = await async_client.get( + "/api/data/weather/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + + @pytest.mark.anyio + async def test_get_weather_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_weather = AsyncMock( + side_effect=Exception("service error") + ) + + response = await async_client.get( + "/api/data/weather/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_weather_returns_403_without_auth(self, async_client): + response = await async_client.get( + "/api/data/weather/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_thi_returns_200( + self, async_client, openweathermap_srv, auth_headers, mock_thi_data_out + ): + openweathermap_srv.get_thi = AsyncMock(return_value=mock_thi_data_out) + + response = await async_client.get( + "/api/data/thi/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + + @pytest.mark.anyio + async def test_get_thi_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_thi = AsyncMock( + side_effect=Exception("service error") + ) + + response = await async_client.get( + "/api/data/thi/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_thi_returns_403_without_auth(self, async_client): + response = await async_client.get( + "/api/data/thi/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + + @pytest.mark.anyio + async def test_get_thi_ld_returns_200( + self, async_client, openweathermap_srv, auth_headers, mock_jsonld_response + ): + # ocsm=True path — returns jsonld + openweathermap_srv.get_thi = AsyncMock(return_value=mock_jsonld_response) + + response = await async_client.get( + "/api/linkeddata/thi/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + # Verify ocsm=True was passed through + openweathermap_srv.get_thi.assert_called_once_with( + BASE_PARAMS["lat"], BASE_PARAMS["lon"], ocsm=True + ) + + @pytest.mark.anyio + async def test_get_thi_ld_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_thi = AsyncMock( + side_effect=Exception("service error") + ) + + response = await async_client.get( + "/api/linkeddata/thi/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_thi_ld_returns_403_without_auth(self, async_client): + response = await async_client.get( + "/api/linkeddata/thi/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_flight_forecast_for_all_uavs_returns_200( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_flight_forecast_for_all_uavs = AsyncMock( + return_value=[] + ) + + response = await async_client.get( + "/api/data/flight-forecast5/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + + @pytest.mark.anyio + async def test_get_flight_forecast_for_all_uavs_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + + + openweathermap_srv.get_flight_forecast_for_all_uavs = AsyncMock( + side_effect=HTTPException(status_code=500, detail="service error") + ) + + response = await async_client.get( + "/api/data/flight-forecast5/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_flight_forecast_for_all_uavs_returns_403_without_auth( + self, async_client + ): + response = await async_client.get( + "/api/data/flight-forecast5/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_flight_forecast_for_uav_returns_200( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_flight_forecast_for_uav = AsyncMock(return_value=[]) + + response = await async_client.get( + "/api/data/flight-forecast5/DJI/", + params=BASE_PARAMS, + headers=auth_headers, + ) + + assert response.status_code == 200 + openweathermap_srv.get_flight_forecast_for_uav.assert_called_once_with( + BASE_PARAMS["lat"], BASE_PARAMS["lon"], "DJI" + ) + + @pytest.mark.anyio + async def test_get_flight_forecast_for_uav_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_flight_forecast_for_uav = AsyncMock( + side_effect=HTTPException(status_code=500, detail="service error") + ) + + response = await async_client.get( + "/api/data/flight-forecast5/DJI/", + params=BASE_PARAMS, + headers=auth_headers, + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_flight_forecast_for_uav_returns_403_without_auth( + self, async_client + ): + response = await async_client.get( + "/api/data/flight-forecast5/DJI/", params=BASE_PARAMS + ) + + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_spray_forecast_returns_200( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_spray_forecast = AsyncMock(return_value=[]) + + response = await async_client.get( + "/api/data/spray-forecast/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 200 + + @pytest.mark.anyio + async def test_get_spray_forecast_returns_500_on_error( + self, async_client, openweathermap_srv, auth_headers + ): + openweathermap_srv.get_spray_forecast = AsyncMock( + side_effect=HTTPException(status_code=500, detail="service error") + ) + + response = await async_client.get( + "/api/data/spray-forecast/", params=BASE_PARAMS, headers=auth_headers + ) + + assert response.status_code == 500 + + @pytest.mark.anyio + async def test_get_spray_forecast_returns_403_without_auth(self, async_client): + response = await async_client.get( + "/api/data/spray-forecast/", params=BASE_PARAMS + ) + + assert response.status_code == 403 \ No newline at end of file From 9a4b70d5bfeaa3bc7c2bf889fec058eff5a9156d Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 14:44:37 +0200 Subject: [PATCH 07/10] test locations --- tests/api/api_v1/test_locations.py | 199 +++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/tests/api/api_v1/test_locations.py b/tests/api/api_v1/test_locations.py index e69de29..bc36cb3 100644 --- a/tests/api/api_v1/test_locations.py +++ b/tests/api/api_v1/test_locations.py @@ -0,0 +1,199 @@ +import pytest +from unittest.mock import AsyncMock, patch +from src.models.history_data import CachedLocation + +MOCK_LAT = 40.7128 +MOCK_LON = -74.0060 + +class TestLocationRoutes: + """ + Integration tests for /api/v1/locations/ routes. + Uses the in-memory MongoDB (AsyncMongoMockClient) via the app fixture. + Data is inserted directly into the mock DB before each test that needs it. + + NOTE: exists-in-radius is skipped — it relies on MongoDB $near geospatial + queries which are not supported by mongomock. + """ + + # Runs after every test to keep the DB clean. + @pytest.fixture(autouse=True) + async def clean_db(self): + yield + await CachedLocation.find_all().delete() + + # Inserts a real CachedLocation document into the mock DB. + # Returns the inserted document so tests can reference its id. + async def _insert_location(self, name="Test Location", location=dict): + doc = CachedLocation(name=name, location=location) + await doc.insert() + return doc + + @pytest.mark.anyio + async def test_list_locations_returns_200_with_empty_db( + self, async_client, auth_headers + ): + response = await async_client.get( + "/api/v1/locations/locations/", headers=auth_headers + ) + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.anyio + async def test_list_locations_returns_inserted_documents( + self, async_client, auth_headers, mock_location + ): + await self._insert_location("Location A", location=mock_location) + await self._insert_location("Location B", location=mock_location) + + response = await async_client.get( + "/api/v1/locations/locations/", headers=auth_headers + ) + + assert response.status_code == 200 + assert len(response.json()) == 2 + names = [loc["name"] for loc in response.json()] + assert "Location A" in names + assert "Location B" in names + + @pytest.mark.anyio + async def test_list_locations_returns_403_without_auth(self, async_client): + response = await async_client.get("/api/v1/locations/locations/") + assert response.status_code == 403 + + @pytest.mark.anyio + async def test_get_location_by_coordinates_returns_200( + self, async_client, auth_headers, mock_location + ): + await self._insert_location(location=mock_location) + + response = await async_client.get( + "/api/v1/locations/locations/by-coordinates/", + params={"lat": MOCK_LAT, "lon": MOCK_LON}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.json()["lat"] == MOCK_LAT + assert response.json()["lon"] == MOCK_LON + + @pytest.mark.anyio + async def test_get_location_by_coordinates_returns_404_when_not_found( + self, async_client, auth_headers + ): + # Nothing inserted — DB is empty + response = await async_client.get( + "/api/v1/locations/locations/by-coordinates/", + params={"lat": MOCK_LAT, "lon": MOCK_LON}, + headers=auth_headers, + ) + + assert response.status_code == 404 + + @pytest.mark.anyio + async def test_get_location_by_coordinates_returns_403_without_auth( + self, async_client + ): + response = await async_client.get( + "/api/v1/locations/locations/by-coordinates/", + params={"lat": MOCK_LAT, "lon": MOCK_LON}, + ) + + assert response.status_code == 403 + + + @pytest.mark.skip( + reason="Uses MongoDB $near geospatial query — not supported by mongomock. " + "Requires a real MongoDB instance to test meaningfully." + ) + async def test_check_location_exists_in_radius(self): + pass + + @pytest.mark.anyio + async def test_add_locations_returns_200_and_inserts( + self, async_client, auth_headers + ): + payload = { + "locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}] + } + + with patch( + "src.api.api_v1.endpoints.locations.fetch_and_cache_last_month", + new_callable=AsyncMock + ): + response = await async_client.post( + "/api/v1/locations/locations/", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 200 + # Verify it actually landed in the DB + docs = await CachedLocation.find_all().to_list() + assert len(docs) == 1 + assert docs[0].name == "Farm A" + + @pytest.mark.anyio + async def test_add_locations_skips_existing_location( + self, async_client, auth_headers, mock_location + ): + await self._insert_location("Farm A", location=mock_location) + + payload = { + "locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}] + } + + with patch( + "src.api.api_v1.endpoints.locations.fetch_and_cache_last_month", + new_callable=AsyncMock + ): + response = await async_client.post( + "/api/v1/locations/locations/", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 200 + # Still only one document — duplicate was skipped + docs = await CachedLocation.find_all().to_list() + assert len(docs) == 1 + + @pytest.mark.anyio + async def test_add_locations_returns_403_without_auth(self, async_client): + payload = { + "locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}] + } + response = await async_client.post("/api/v1/locations/locations/", json=payload) + assert response.status_code == 403 + + @pytest.mark.skip( + reason="Uses dao.find_location_nearby which relies on MongoDB $near " + "geospatial query — not supported by mongomock." + ) + async def test_add_unique_locations(self): + pass + + @pytest.mark.anyio + async def test_delete_location_returns_200( + self, async_client, auth_headers, mock_location + ): + doc = await self._insert_location(location=mock_location) + + with patch( + "src.api.api_v1.endpoints.locations.scheduler" + ) as mock_scheduler: + mock_scheduler.get_job.return_value = None + response = await async_client.delete( + f"/api/v1/locations/locations/{doc.id}/", + headers=auth_headers, + ) + + assert response.status_code == 200 + # Verify it was actually removed from the DB + remaining = await CachedLocation.find_all().to_list() + assert len(remaining) == 0 + + + @pytest.mark.anyio + async def test_delete_location_returns_403_without_auth(self, async_client): + response = await async_client.delete("/api/v1/locations/locations/some-id/") + assert response.status_code == 403 \ No newline at end of file From 31a910cd723d2244eea30ebaca2e1663df3a2596 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 14:45:13 +0200 Subject: [PATCH 08/10] add mock_location fixture --- tests/api/data/test_data_routes.py | 17 ++--------------- tests/conftest.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/api/data/test_data_routes.py b/tests/api/data/test_data_routes.py index 61227f2..52b6e92 100644 --- a/tests/api/data/test_data_routes.py +++ b/tests/api/data/test_data_routes.py @@ -6,19 +6,7 @@ BASE_PARAMS = {"lat": 40.7128, "lon": -74.0060} -MOCK_LOCATION = { - "type": "Point", - "coordinates": [40.7128, -74.0060] -} - -MOCK_SPATIAL_ENTITY = { - "location": MOCK_LOCATION -} - class TestDataRoutes: - @pytest.fixture - def auth_headers(self, jwt_token): - return {"Authorization": f"Bearer {jwt_token}"} @pytest.fixture def mock_weather_data_out(self): @@ -38,10 +26,10 @@ def mock_weather_data_out(self): } } @pytest.fixture - def mock_thi_data_out(self): + def mock_thi_data_out(self, mock_location): return { "id": str(uuid.uuid4()), - "spatial_entity": MOCK_SPATIAL_ENTITY, + "spatial_entity": {"location": mock_location}, "thi": 72.5 } @pytest.fixture @@ -69,7 +57,6 @@ async def test_get_weather_forecast5days_returns_200( response = await async_client.get( "/api/data/forecast5/", params=BASE_PARAMS, headers=auth_headers ) - assert response.status_code == 200 openweathermap_srv.get_weather_forecast5days.assert_called_once_with( BASE_PARAMS["lat"], BASE_PARAMS["lon"] diff --git a/tests/conftest.py b/tests/conftest.py index ec416db..4c5626f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ import src.utils as utils from src.api.api import data_router +from src.api.api_v1.api import api_router from src.core import config from src.core.dao import Dao from src.external_services.openweathermap import OpenWeatherMap @@ -43,6 +44,7 @@ async def openweathermap_srv(): async def app(openweathermap_srv): _app = create_app() _app.include_router(data_router) + _app.include_router(api_router, prefix="/api/v1") # Mock the MongoDB client mongodb_client = AsyncMongoMockClient() @@ -75,6 +77,10 @@ def jwt_token(): token = jwt.encode(payload, config.KEY, algorithm=config.ALGORITHM) return token +@pytest.fixture +def auth_headers(jwt_token): + return {"Authorization": f"Bearer {jwt_token}"} + @pytest.fixture def mock_uav(app): return UAVModel( @@ -119,8 +125,11 @@ def mock_owm_current_weather_response(): "cod": 200, } +@pytest.fixture +def mock_location(): + return {"type": "Point", "coordinates": [-74.0060, 40.7128]} + -# not used anywhere @pytest.fixture def mock_flight_response(): """Mock flight forecast response""" From 56bc37ae9bb07a4fe9973e72963ee95c046b25e9 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 19:16:18 +0200 Subject: [PATCH 09/10] annotate _insert_location helper --- tests/api/api_v1/test_locations.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/api/api_v1/test_locations.py b/tests/api/api_v1/test_locations.py index bc36cb3..b7a1ae1 100644 --- a/tests/api/api_v1/test_locations.py +++ b/tests/api/api_v1/test_locations.py @@ -10,9 +10,6 @@ class TestLocationRoutes: Integration tests for /api/v1/locations/ routes. Uses the in-memory MongoDB (AsyncMongoMockClient) via the app fixture. Data is inserted directly into the mock DB before each test that needs it. - - NOTE: exists-in-radius is skipped — it relies on MongoDB $near geospatial - queries which are not supported by mongomock. """ # Runs after every test to keep the DB clean. @@ -23,7 +20,7 @@ async def clean_db(self): # Inserts a real CachedLocation document into the mock DB. # Returns the inserted document so tests can reference its id. - async def _insert_location(self, name="Test Location", location=dict): + async def _insert_location(self, location:dict, name: str = "Test Location"): doc = CachedLocation(name=name, location=location) await doc.insert() return doc @@ -42,8 +39,8 @@ async def test_list_locations_returns_200_with_empty_db( async def test_list_locations_returns_inserted_documents( self, async_client, auth_headers, mock_location ): - await self._insert_location("Location A", location=mock_location) - await self._insert_location("Location B", location=mock_location) + await self._insert_location(mock_location, "Location A") + await self._insert_location(mock_location, "Location B") response = await async_client.get( "/api/v1/locations/locations/", headers=auth_headers @@ -64,7 +61,7 @@ async def test_list_locations_returns_403_without_auth(self, async_client): async def test_get_location_by_coordinates_returns_200( self, async_client, auth_headers, mock_location ): - await self._insert_location(location=mock_location) + await self._insert_location(mock_location) response = await async_client.get( "/api/v1/locations/locations/by-coordinates/", @@ -136,7 +133,7 @@ async def test_add_locations_returns_200_and_inserts( async def test_add_locations_skips_existing_location( self, async_client, auth_headers, mock_location ): - await self._insert_location("Farm A", location=mock_location) + await self._insert_location(mock_location, "Farm A") payload = { "locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}] @@ -176,7 +173,7 @@ async def test_add_unique_locations(self): async def test_delete_location_returns_200( self, async_client, auth_headers, mock_location ): - doc = await self._insert_location(location=mock_location) + doc = await self._insert_location(mock_location) with patch( "src.api.api_v1.endpoints.locations.scheduler" From fa7029f295152a4a770fe4ebee5cf18a09e7655d Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Fri, 20 Mar 2026 19:45:38 +0200 Subject: [PATCH 10/10] history routes tests --- tests/api/api_v1/test_history.py | 167 ++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/tests/api/api_v1/test_history.py b/tests/api/api_v1/test_history.py index 1e7d08c..0574eb4 100644 --- a/tests/api/api_v1/test_history.py +++ b/tests/api/api_v1/test_history.py @@ -1,5 +1,168 @@ import pytest +from datetime import date, datetime +from unittest.mock import AsyncMock, patch -class TestHistory: - pass \ No newline at end of file +BASE_QUERY = { + "lat": 40.7128, + "lon": -74.0060, + "start": "2024-01-01", + "end": "2024-01-02", + "variables": ["temperature_2m"], + "radius_km": 10.0 +} + +class TestHistoryRoutes: + """ + Tests for /api/v1/history/ routes. + + Both routes use MongoDB $near geospatial queries which mongomock does not + support! We test the cache miss path only `find_one` is patched to return + None, triggering the Open-Meteo fallback. The cache hit path (reading from + DB) requires a real MongoDB instance and is intentionally skipped. + """ + + @pytest.fixture + def mock_hourly_observation(self): + """Single hourly observation — reusable unit of hourly data.""" + return { + "timestamp": datetime(2024, 1, 1, 12, 0, 0).isoformat(), + "values": { + "temperature_2m": 25.5, + "humidity_2m": 60.0, + "wind_speed_2m": 5.2 + } + } + + @pytest.fixture + def mock_daily_observation(self): + """Single daily observation — reusable unit of daily data.""" + return { + "date": date(2024, 1, 1).isoformat(), + "values": { + "temperature_2m_max": 28.0, + "temperature_2m_min": 15.0, + "humidity_2m_max": 70.0 + } + } + + @pytest.fixture + def mock_hourly_response(self, mock_hourly_observation): + return { + "location": {"lat": 40.7128, "lon": -74.0060}, + "data": [mock_hourly_observation], + "source": "openmeteo" + } + + + @pytest.fixture + def mock_daily_response(self, mock_daily_observation): + """ + Shaped like DailyResponse. + Same principle as mock_hourly_response. + """ + return { + "location": {"lat": 40.7128, "lon": -74.0060}, + "data": [mock_daily_observation], + "source": "openmeteo" + } + + @pytest.mark.anyio + async def test_get_hourly_history_cache_miss_fetches_from_openmeteo( + self, async_client, auth_headers, mock_hourly_response + ): + mock_provider = AsyncMock() + mock_provider.get_hourly_history.return_value = \ + mock_hourly_response["data"] + + with patch( + "src.api.api_v1.endpoints.history.HourlyHistory.find_one", + new_callable=AsyncMock, + return_value=None # cache miss + ), patch( + "src.api.api_v1.endpoints.history.WeatherClientFactory.get_provider", + return_value=mock_provider + ): + response = await async_client.post( + "/api/v1/history/hourly/", + json=BASE_QUERY, + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["source"] == "openmeteo" + assert data["location"] == {"lat": BASE_QUERY["lat"], "lon": BASE_QUERY["lon"]} + mock_provider.get_hourly_history.assert_called_once_with( + BASE_QUERY["lat"], + BASE_QUERY["lon"], + date.fromisoformat(BASE_QUERY["start"]), + date.fromisoformat(BASE_QUERY["end"]), + BASE_QUERY["variables"], + ) + + @pytest.mark.anyio + async def test_get_hourly_history_returns_403_without_auth( + self, async_client + ): + response = await async_client.post( + "/api/v1/history/hourly/", json=BASE_QUERY + ) + assert response.status_code == 403 + + @pytest.mark.skip( + reason="Cache hit path uses $near geospatial query via find_one and " + "find_many — not supported by mongomock. Would require a real MongoDB." + ) + async def test_get_hourly_history_cache_hit_returns_db_data(self): + pass + + @pytest.mark.anyio + async def test_get_daily_history_cache_miss_fetches_from_openmeteo( + self, async_client, auth_headers, mock_daily_response + ): + mock_provider = AsyncMock() + mock_provider.get_daily_history.return_value = \ + mock_daily_response["data"] + + with patch( + "src.api.api_v1.endpoints.history.DailyHistory.find_one", + new_callable=AsyncMock, + return_value=None # cache miss + ), patch( + "src.api.api_v1.endpoints.history.WeatherClientFactory.get_provider", + return_value=mock_provider + ): + response = await async_client.post( + "/api/v1/history/daily/", + json=BASE_QUERY, + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["source"] == "openmeteo" + assert data["location"] == {"lat": BASE_QUERY["lat"], "lon": BASE_QUERY["lon"]} + mock_provider.get_daily_history.assert_called_once_with( + BASE_QUERY["lat"], + BASE_QUERY["lon"], + date.fromisoformat(BASE_QUERY["start"]), + date.fromisoformat(BASE_QUERY["end"]), + BASE_QUERY["variables"], + ) + + @pytest.mark.anyio + async def test_get_daily_history_returns_403_without_auth( + self, async_client + ): + response = await async_client.post( + "/api/v1/history/daily/", json=BASE_QUERY + ) + assert response.status_code == 403 + + @pytest.mark.skip( + reason="Cache hit path uses $near geospatial query via find_one and " + "find_many — not supported by mongomock. Would require a real MongoDB." + ) + async def test_get_daily_history_cache_hit_returns_db_data(self): + pass \ No newline at end of file