Skip to content

Commit 61ab608

Browse files
test: add API route tests and fix test suite (#65) (#89)
* Explicit asyncio loop scope * move fixtures inside configuration file to be shared across all tests * rename test_jwt_token to jwt_token * move weather_data mock fixture in conftest * get_thi unitests * test data routes * test locations * add mock_location fixture * annotate _insert_location helper * history routes tests --------- Co-authored-by: fedjo <mari.giorgos@gmail.com>
1 parent 923ecb0 commit 61ab608

10 files changed

Lines changed: 881 additions & 142 deletions

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
[pytest]
22
# Pytest configuration for OpenAgri-WeatherService
33

4+
# PytestDeprecationWarning
5+
asyncio_default_fixture_loop_scope = function
46
# Test discovery patterns
57
python_files = test_*.py
68
python_classes = Test*
@@ -49,3 +51,4 @@ exclude_lines =
4951
if TYPE_CHECKING:
5052
@abstractmethod
5153

54+

tests/api/api_v1/test_history.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import pytest
2+
from datetime import date, datetime
3+
from unittest.mock import AsyncMock, patch
4+
5+
6+
BASE_QUERY = {
7+
"lat": 40.7128,
8+
"lon": -74.0060,
9+
"start": "2024-01-01",
10+
"end": "2024-01-02",
11+
"variables": ["temperature_2m"],
12+
"radius_km": 10.0
13+
}
14+
15+
class TestHistoryRoutes:
16+
"""
17+
Tests for /api/v1/history/ routes.
18+
19+
Both routes use MongoDB $near geospatial queries which mongomock does not
20+
support! We test the cache miss path only `find_one` is patched to return
21+
None, triggering the Open-Meteo fallback. The cache hit path (reading from
22+
DB) requires a real MongoDB instance and is intentionally skipped.
23+
"""
24+
25+
@pytest.fixture
26+
def mock_hourly_observation(self):
27+
"""Single hourly observation — reusable unit of hourly data."""
28+
return {
29+
"timestamp": datetime(2024, 1, 1, 12, 0, 0).isoformat(),
30+
"values": {
31+
"temperature_2m": 25.5,
32+
"humidity_2m": 60.0,
33+
"wind_speed_2m": 5.2
34+
}
35+
}
36+
37+
@pytest.fixture
38+
def mock_daily_observation(self):
39+
"""Single daily observation — reusable unit of daily data."""
40+
return {
41+
"date": date(2024, 1, 1).isoformat(),
42+
"values": {
43+
"temperature_2m_max": 28.0,
44+
"temperature_2m_min": 15.0,
45+
"humidity_2m_max": 70.0
46+
}
47+
}
48+
49+
@pytest.fixture
50+
def mock_hourly_response(self, mock_hourly_observation):
51+
return {
52+
"location": {"lat": 40.7128, "lon": -74.0060},
53+
"data": [mock_hourly_observation],
54+
"source": "openmeteo"
55+
}
56+
57+
58+
@pytest.fixture
59+
def mock_daily_response(self, mock_daily_observation):
60+
"""
61+
Shaped like DailyResponse.
62+
Same principle as mock_hourly_response.
63+
"""
64+
return {
65+
"location": {"lat": 40.7128, "lon": -74.0060},
66+
"data": [mock_daily_observation],
67+
"source": "openmeteo"
68+
}
69+
70+
@pytest.mark.anyio
71+
async def test_get_hourly_history_cache_miss_fetches_from_openmeteo(
72+
self, async_client, auth_headers, mock_hourly_response
73+
):
74+
mock_provider = AsyncMock()
75+
mock_provider.get_hourly_history.return_value = \
76+
mock_hourly_response["data"]
77+
78+
with patch(
79+
"src.api.api_v1.endpoints.history.HourlyHistory.find_one",
80+
new_callable=AsyncMock,
81+
return_value=None # cache miss
82+
), patch(
83+
"src.api.api_v1.endpoints.history.WeatherClientFactory.get_provider",
84+
return_value=mock_provider
85+
):
86+
response = await async_client.post(
87+
"/api/v1/history/hourly/",
88+
json=BASE_QUERY,
89+
headers=auth_headers,
90+
)
91+
92+
assert response.status_code == 200
93+
data = response.json()
94+
assert data["source"] == "openmeteo"
95+
assert data["location"] == {"lat": BASE_QUERY["lat"], "lon": BASE_QUERY["lon"]}
96+
mock_provider.get_hourly_history.assert_called_once_with(
97+
BASE_QUERY["lat"],
98+
BASE_QUERY["lon"],
99+
date.fromisoformat(BASE_QUERY["start"]),
100+
date.fromisoformat(BASE_QUERY["end"]),
101+
BASE_QUERY["variables"],
102+
)
103+
104+
@pytest.mark.anyio
105+
async def test_get_hourly_history_returns_403_without_auth(
106+
self, async_client
107+
):
108+
response = await async_client.post(
109+
"/api/v1/history/hourly/", json=BASE_QUERY
110+
)
111+
assert response.status_code == 403
112+
113+
@pytest.mark.skip(
114+
reason="Cache hit path uses $near geospatial query via find_one and "
115+
"find_many — not supported by mongomock. Would require a real MongoDB."
116+
)
117+
async def test_get_hourly_history_cache_hit_returns_db_data(self):
118+
pass
119+
120+
@pytest.mark.anyio
121+
async def test_get_daily_history_cache_miss_fetches_from_openmeteo(
122+
self, async_client, auth_headers, mock_daily_response
123+
):
124+
mock_provider = AsyncMock()
125+
mock_provider.get_daily_history.return_value = \
126+
mock_daily_response["data"]
127+
128+
with patch(
129+
"src.api.api_v1.endpoints.history.DailyHistory.find_one",
130+
new_callable=AsyncMock,
131+
return_value=None # cache miss
132+
), patch(
133+
"src.api.api_v1.endpoints.history.WeatherClientFactory.get_provider",
134+
return_value=mock_provider
135+
):
136+
response = await async_client.post(
137+
"/api/v1/history/daily/",
138+
json=BASE_QUERY,
139+
headers=auth_headers,
140+
)
141+
142+
assert response.status_code == 200
143+
data = response.json()
144+
assert data["source"] == "openmeteo"
145+
assert data["location"] == {"lat": BASE_QUERY["lat"], "lon": BASE_QUERY["lon"]}
146+
mock_provider.get_daily_history.assert_called_once_with(
147+
BASE_QUERY["lat"],
148+
BASE_QUERY["lon"],
149+
date.fromisoformat(BASE_QUERY["start"]),
150+
date.fromisoformat(BASE_QUERY["end"]),
151+
BASE_QUERY["variables"],
152+
)
153+
154+
@pytest.mark.anyio
155+
async def test_get_daily_history_returns_403_without_auth(
156+
self, async_client
157+
):
158+
response = await async_client.post(
159+
"/api/v1/history/daily/", json=BASE_QUERY
160+
)
161+
assert response.status_code == 403
162+
163+
@pytest.mark.skip(
164+
reason="Cache hit path uses $near geospatial query via find_one and "
165+
"find_many — not supported by mongomock. Would require a real MongoDB."
166+
)
167+
async def test_get_daily_history_cache_hit_returns_db_data(self):
168+
pass

tests/api/api_v1/test_locations.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, patch
3+
from src.models.history_data import CachedLocation
4+
5+
MOCK_LAT = 40.7128
6+
MOCK_LON = -74.0060
7+
8+
class TestLocationRoutes:
9+
"""
10+
Integration tests for /api/v1/locations/ routes.
11+
Uses the in-memory MongoDB (AsyncMongoMockClient) via the app fixture.
12+
Data is inserted directly into the mock DB before each test that needs it.
13+
"""
14+
15+
# Runs after every test to keep the DB clean.
16+
@pytest.fixture(autouse=True)
17+
async def clean_db(self):
18+
yield
19+
await CachedLocation.find_all().delete()
20+
21+
# Inserts a real CachedLocation document into the mock DB.
22+
# Returns the inserted document so tests can reference its id.
23+
async def _insert_location(self, location:dict, name: str = "Test Location"):
24+
doc = CachedLocation(name=name, location=location)
25+
await doc.insert()
26+
return doc
27+
28+
@pytest.mark.anyio
29+
async def test_list_locations_returns_200_with_empty_db(
30+
self, async_client, auth_headers
31+
):
32+
response = await async_client.get(
33+
"/api/v1/locations/locations/", headers=auth_headers
34+
)
35+
assert response.status_code == 200
36+
assert response.json() == []
37+
38+
@pytest.mark.anyio
39+
async def test_list_locations_returns_inserted_documents(
40+
self, async_client, auth_headers, mock_location
41+
):
42+
await self._insert_location(mock_location, "Location A")
43+
await self._insert_location(mock_location, "Location B")
44+
45+
response = await async_client.get(
46+
"/api/v1/locations/locations/", headers=auth_headers
47+
)
48+
49+
assert response.status_code == 200
50+
assert len(response.json()) == 2
51+
names = [loc["name"] for loc in response.json()]
52+
assert "Location A" in names
53+
assert "Location B" in names
54+
55+
@pytest.mark.anyio
56+
async def test_list_locations_returns_403_without_auth(self, async_client):
57+
response = await async_client.get("/api/v1/locations/locations/")
58+
assert response.status_code == 403
59+
60+
@pytest.mark.anyio
61+
async def test_get_location_by_coordinates_returns_200(
62+
self, async_client, auth_headers, mock_location
63+
):
64+
await self._insert_location(mock_location)
65+
66+
response = await async_client.get(
67+
"/api/v1/locations/locations/by-coordinates/",
68+
params={"lat": MOCK_LAT, "lon": MOCK_LON},
69+
headers=auth_headers,
70+
)
71+
72+
assert response.status_code == 200
73+
assert response.json()["lat"] == MOCK_LAT
74+
assert response.json()["lon"] == MOCK_LON
75+
76+
@pytest.mark.anyio
77+
async def test_get_location_by_coordinates_returns_404_when_not_found(
78+
self, async_client, auth_headers
79+
):
80+
# Nothing inserted — DB is empty
81+
response = await async_client.get(
82+
"/api/v1/locations/locations/by-coordinates/",
83+
params={"lat": MOCK_LAT, "lon": MOCK_LON},
84+
headers=auth_headers,
85+
)
86+
87+
assert response.status_code == 404
88+
89+
@pytest.mark.anyio
90+
async def test_get_location_by_coordinates_returns_403_without_auth(
91+
self, async_client
92+
):
93+
response = await async_client.get(
94+
"/api/v1/locations/locations/by-coordinates/",
95+
params={"lat": MOCK_LAT, "lon": MOCK_LON},
96+
)
97+
98+
assert response.status_code == 403
99+
100+
101+
@pytest.mark.skip(
102+
reason="Uses MongoDB $near geospatial query — not supported by mongomock. "
103+
"Requires a real MongoDB instance to test meaningfully."
104+
)
105+
async def test_check_location_exists_in_radius(self):
106+
pass
107+
108+
@pytest.mark.anyio
109+
async def test_add_locations_returns_200_and_inserts(
110+
self, async_client, auth_headers
111+
):
112+
payload = {
113+
"locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}]
114+
}
115+
116+
with patch(
117+
"src.api.api_v1.endpoints.locations.fetch_and_cache_last_month",
118+
new_callable=AsyncMock
119+
):
120+
response = await async_client.post(
121+
"/api/v1/locations/locations/",
122+
json=payload,
123+
headers=auth_headers,
124+
)
125+
126+
assert response.status_code == 200
127+
# Verify it actually landed in the DB
128+
docs = await CachedLocation.find_all().to_list()
129+
assert len(docs) == 1
130+
assert docs[0].name == "Farm A"
131+
132+
@pytest.mark.anyio
133+
async def test_add_locations_skips_existing_location(
134+
self, async_client, auth_headers, mock_location
135+
):
136+
await self._insert_location(mock_location, "Farm A")
137+
138+
payload = {
139+
"locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}]
140+
}
141+
142+
with patch(
143+
"src.api.api_v1.endpoints.locations.fetch_and_cache_last_month",
144+
new_callable=AsyncMock
145+
):
146+
response = await async_client.post(
147+
"/api/v1/locations/locations/",
148+
json=payload,
149+
headers=auth_headers,
150+
)
151+
152+
assert response.status_code == 200
153+
# Still only one document — duplicate was skipped
154+
docs = await CachedLocation.find_all().to_list()
155+
assert len(docs) == 1
156+
157+
@pytest.mark.anyio
158+
async def test_add_locations_returns_403_without_auth(self, async_client):
159+
payload = {
160+
"locations": [{"name": "Farm A", "lat": MOCK_LAT, "lon": MOCK_LON}]
161+
}
162+
response = await async_client.post("/api/v1/locations/locations/", json=payload)
163+
assert response.status_code == 403
164+
165+
@pytest.mark.skip(
166+
reason="Uses dao.find_location_nearby which relies on MongoDB $near "
167+
"geospatial query — not supported by mongomock."
168+
)
169+
async def test_add_unique_locations(self):
170+
pass
171+
172+
@pytest.mark.anyio
173+
async def test_delete_location_returns_200(
174+
self, async_client, auth_headers, mock_location
175+
):
176+
doc = await self._insert_location(mock_location)
177+
178+
with patch(
179+
"src.api.api_v1.endpoints.locations.scheduler"
180+
) as mock_scheduler:
181+
mock_scheduler.get_job.return_value = None
182+
response = await async_client.delete(
183+
f"/api/v1/locations/locations/{doc.id}/",
184+
headers=auth_headers,
185+
)
186+
187+
assert response.status_code == 200
188+
# Verify it was actually removed from the DB
189+
remaining = await CachedLocation.find_all().to_list()
190+
assert len(remaining) == 0
191+
192+
193+
@pytest.mark.anyio
194+
async def test_delete_location_returns_403_without_auth(self, async_client):
195+
response = await async_client.delete("/api/v1/locations/locations/some-id/")
196+
assert response.status_code == 403

0 commit comments

Comments
 (0)