Files
Ai/farm_data/tests/test_farm_detail_api.py
T
2026-04-25 17:22:41 +03:30

349 lines
13 KiB
Python

from datetime import date
from unittest.mock import patch
import uuid
from django.test import TestCase
from rest_framework.test import APIClient
from location_data.models import SoilDepthData, SoilLocation
from farm_data.models import SensorData
from irrigation.models import IrrigationMethod
from plant.models import Plant
from weather.models import WeatherForecast
from farm_data.services import resolve_center_location_from_boundary
def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[lon - delta, lat - delta],
[lon + delta, lat - delta],
[lon + delta, lat + delta],
[lon - delta, lat + delta],
[lon - delta, lat - delta],
]
],
}
class FarmDetailApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.location = SoilLocation.objects.create(
latitude="35.700000",
longitude="51.400000",
farm_boundary={"type": "Polygon", "coordinates": []},
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="0-5cm",
clay=22.0,
nitrogen=10.0,
sand=40.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="5-15cm",
clay=18.0,
nitrogen=8.0,
)
self.weather = WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2026, 4, 10),
temperature_min=12.0,
temperature_max=23.0,
temperature_mean=18.0,
precipitation=1.2,
humidity_mean=52.0,
)
self.plant1 = Plant.objects.create(name="گوجه‌فرنگی")
self.plant2 = Plant.objects.create(name="خیار")
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای")
self.farm_uuid = uuid.uuid4()
self.farm = SensorData.objects.create(
farm_uuid=self.farm_uuid,
center_location=self.location,
weather_forecast=self.weather,
irrigation_method=self.irrigation_method,
sensor_payload={
"sensor-7-1": {
"soil_moisture": 33.5,
"nitrogen": 99.0,
}
},
)
self.farm.plants.set([self.plant2, self.plant1])
def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self):
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertNotIn("farm_uuid", payload)
self.assertEqual(payload["center_location"]["id"], self.location.id)
self.assertEqual(payload["weather"]["id"], self.weather.id)
self.assertEqual(
payload["sensor_payload"]["sensor-7-1"]["soil_moisture"],
33.5,
)
resolved_metrics = payload["soil"]["resolved_metrics"]
metric_sources = payload["soil"]["metric_sources"]
self.assertEqual(resolved_metrics["nitrogen"], 99.0)
self.assertEqual(metric_sources["nitrogen"]["type"], "sensor")
self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value")
self.assertEqual(resolved_metrics["clay"], 22.0)
self.assertEqual(metric_sources["clay"], "soil")
self.assertEqual(len(payload["soil"]["depths"]), 2)
self.assertCountEqual(payload["plant_ids"], [self.plant1.id, self.plant2.id])
self.assertEqual(len(payload["plants"]), 2)
returned_plants = {item["id"]: item for item in payload["plants"]}
self.assertEqual(returned_plants[self.plant1.id]["name"], self.plant1.name)
self.assertEqual(returned_plants[self.plant2.id]["name"], self.plant2.name)
self.assertIn("light", returned_plants[self.plant1.id])
self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id)
self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name)
def test_returns_404_when_farm_is_missing(self):
response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "farm یافت نشد.")
def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self):
self.farm.sensor_payload = {
"sensor-a": {
"soil_moisture": 20.0,
"nitrogen": 90.0,
"status": "ok",
},
"sensor-b": {
"soil_moisture": 40.0,
"nitrogen": 110.0,
"status": "needs-check",
},
}
self.farm.save(update_fields=["sensor_payload"])
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
resolved_metrics = payload["soil"]["resolved_metrics"]
metric_sources = payload["soil"]["metric_sources"]
self.assertEqual(resolved_metrics["soil_moisture"], 30.0)
self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average")
self.assertCountEqual(
metric_sources["soil_moisture"]["sensor_keys"],
["sensor-a", "sensor-b"],
)
self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0])
self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"])
self.assertEqual(metric_sources["status"]["strategy"], "distinct_values")
class FarmDataUpsertApiTests(TestCase):
def setUp(self):
self.client = APIClient()
self.location = SoilLocation.objects.create(
latitude="35.710000",
longitude="51.410000",
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="0-5cm",
clay=20.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="5-15cm",
clay=18.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="15-30cm",
clay=16.0,
)
self.boundary = square_boundary_for_center(35.71, 51.41)
self.weather = WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2026, 4, 11),
temperature_min=11.0,
temperature_max=24.0,
temperature_mean=17.5,
)
self.irrigation_method = IrrigationMethod.objects.create(name="بارانی")
def test_post_creates_farm_data_with_explicit_farm_uuid(self):
farm_uuid = uuid.uuid4()
response = self.client.post(
"/api/farm-data/",
data={
"farm_uuid": str(farm_uuid),
"farm_boundary": self.boundary,
"sensor_payload": {
"sensor-7-1": {
"soil_moisture": 31.2,
"nitrogen": 18.0,
}
},
"irrigation_method_id": self.irrigation_method.id,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["data"]["farm_uuid"], str(farm_uuid))
self.assertEqual(response.json()["data"]["center_location_id"], self.location.id)
self.assertEqual(response.json()["data"]["weather_forecast_id"], self.weather.id)
farm = SensorData.objects.get(farm_uuid=farm_uuid)
self.assertEqual(farm.center_location_id, self.location.id)
self.assertEqual(farm.weather_forecast_id, self.weather.id)
self.assertEqual(farm.irrigation_method_id, self.irrigation_method.id)
self.assertEqual(
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
31.2,
)
def test_post_requires_farm_uuid_in_request_body(self):
response = self.client.post(
"/api/farm-data/",
data={
"farm_boundary": self.boundary,
"sensor_payload": {"sensor-7-1": {"soil_moisture": 31.2}},
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("farm_uuid", response.json()["data"])
@patch("farm_data.services.update_weather_for_location", return_value={"status": "no_data"})
@patch(
"farm_data.services.fetch_soil_data_for_coordinates",
return_value={"status": "completed", "depths": []},
)
def test_post_creates_center_location_from_boundary_when_missing(
self,
_mock_fetch_soil_data_for_coordinates,
_mock_update_weather_for_location,
):
farm_uuid = uuid.uuid4()
response = self.client.post(
"/api/farm-data/",
data={
"farm_uuid": str(farm_uuid),
"farm_boundary": {
"corners": [
{"lat": 50.0, "lon": 50.0},
{"lat": 50.0, "lon": 50.02},
{"lat": 50.02, "lon": 50.02},
{"lat": 50.02, "lon": 50.0},
]
},
"sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}},
},
format="json",
)
self.assertEqual(response.status_code, 201)
farm = SensorData.objects.get(farm_uuid=farm_uuid)
self.assertIsNotNone(farm.center_location_id)
self.assertEqual(str(farm.center_location.latitude), "50.010000")
self.assertEqual(str(farm.center_location.longitude), "50.010000")
self.assertIsNone(farm.weather_forecast_id)
def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self):
location = resolve_center_location_from_boundary(
{
"corners": [
{"lat": 0.0, "lon": 0.0},
{"lat": 0.0, "lon": 4.0},
{"lat": 4.0, "lon": 4.0},
{"lat": 4.0, "lon": 0.0},
{"lat": 1.0, "lon": 0.0},
{"lat": 1.0, "lon": 3.0},
{"lat": 3.0, "lon": 3.0},
{"lat": 3.0, "lon": 1.0},
{"lat": 0.0, "lon": 1.0},
]
}
)
self.assertEqual(str(location.latitude), "2.078947")
self.assertEqual(str(location.longitude), "2.078947")
@patch("farm_data.services.update_weather_for_location")
@patch("farm_data.services.fetch_soil_data_for_coordinates")
def test_post_fetches_missing_location_and_weather_data(
self,
mock_fetch_soil_data_for_coordinates,
mock_update_weather_for_location,
):
missing_boundary = square_boundary_for_center(36.0, 52.0)
farm_uuid = uuid.uuid4()
def soil_side_effect(latitude, longitude, task_id="", progress_callback=None):
location = SoilLocation.objects.get(
latitude="36.000000",
longitude="52.000000",
)
SoilDepthData.objects.update_or_create(
soil_location=location,
depth_label="0-5cm",
defaults={"clay": 20.0},
)
SoilDepthData.objects.update_or_create(
soil_location=location,
depth_label="5-15cm",
defaults={"clay": 18.0},
)
SoilDepthData.objects.update_or_create(
soil_location=location,
depth_label="15-30cm",
defaults={"clay": 16.0},
)
return {"status": "completed", "location_id": location.id, "depths": ["0-5cm", "5-15cm", "15-30cm"]}
def weather_side_effect(location):
WeatherForecast.objects.update_or_create(
location=location,
forecast_date=date(2026, 4, 12),
defaults={
"temperature_min": 10.0,
"temperature_max": 20.0,
"temperature_mean": 15.0,
},
)
return {"status": "success", "location_id": location.id, "days_updated": 1}
mock_fetch_soil_data_for_coordinates.side_effect = soil_side_effect
mock_update_weather_for_location.side_effect = weather_side_effect
response = self.client.post(
"/api/farm-data/",
data={
"farm_uuid": str(farm_uuid),
"farm_boundary": missing_boundary,
"sensor_payload": {"sensor-7-1": {"soil_moisture": 44.0}},
},
format="json",
)
self.assertEqual(response.status_code, 201)
mock_fetch_soil_data_for_coordinates.assert_called_once()
mock_update_weather_for_location.assert_called_once()
farm = SensorData.objects.get(farm_uuid=farm_uuid)
self.assertEqual(farm.center_location.depths.count(), 3)
self.assertIsNotNone(farm.weather_forecast_id)