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 + diff --git a/tests/api/api_v1/test_history.py b/tests/api/api_v1/test_history.py new file mode 100644 index 0000000..0574eb4 --- /dev/null +++ b/tests/api/api_v1/test_history.py @@ -0,0 +1,168 @@ +import pytest +from datetime import date, datetime +from unittest.mock import AsyncMock, patch + + +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 diff --git a/tests/api/api_v1/test_locations.py b/tests/api/api_v1/test_locations.py new file mode 100644 index 0000000..b7a1ae1 --- /dev/null +++ b/tests/api/api_v1/test_locations.py @@ -0,0 +1,196 @@ +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. + """ + + # 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, location:dict, name: str = "Test Location"): + 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(mock_location, "Location A") + await self._insert_location(mock_location, "Location B") + + 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(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(mock_location, "Farm A") + + 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(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 diff --git a/tests/api/data/test_data_routes.py b/tests/api/data/test_data_routes.py new file mode 100644 index 0000000..52b6e92 --- /dev/null +++ b/tests/api/data/test_data_routes.py @@ -0,0 +1,350 @@ +import pytest +from unittest.mock import AsyncMock +import uuid +from fastapi import HTTPException + + +BASE_PARAMS = {"lat": 40.7128, "lon": -74.0060} + +class TestDataRoutes: + + @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, mock_location): + return { + "id": str(uuid.uuid4()), + "spatial_entity": {"location": mock_location}, + "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 diff --git a/tests/conftest.py b/tests/conftest.py index f99400e..4c5626f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,24 @@ """ 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.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 +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 +30,122 @@ 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) + _app.include_router(api_router, prefix="/api/v1") + + # 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 +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", + "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 +def auth_headers(jwt_token): + return {"Authorization": f"Bearer {jwt_token}"} + +@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_owm_current_weather_response(): + """ + Moved here from TestOpenWeatherMapApi so that it can be shared + """ + 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.fixture +def mock_location(): + return {"type": "Point", "coordinates": [-74.0060, 40.7128]} + + +@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..cfa0943 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: @@ -58,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 diff --git a/tests/test_openweathermap.py b/tests/test_openweathermap.py index 441e868..96a24ed 100644 --- a/tests/test_openweathermap.py +++ b/tests/test_openweathermap.py @@ -4,9 +4,8 @@ import pytest from src.external_services import openweathermap -from src.models.point import Point from src.models.prediction import Prediction -from tests.fixtures import openweathermap_srv +from src.models.point import Point class TestOpenWeatherMap: @@ -55,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": {}} + + +