Skip to content
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -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*
Expand Down Expand Up @@ -49,3 +51,4 @@ exclude_lines =
if TYPE_CHECKING:
@abstractmethod


168 changes: 168 additions & 0 deletions tests/api/api_v1/test_history.py
Original file line number Diff line number Diff line change
@@ -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
196 changes: 196 additions & 0 deletions tests/api/api_v1/test_locations.py
Original file line number Diff line number Diff line change
@@ -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
Loading