UPDATE
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
Integration tests in this folder are intended to run against the project's
|
||||
configured MySQL backend, so the API flow is exercised with a real relational
|
||||
database instead of in-memory fixtures.
|
||||
|
||||
Recommended command:
|
||||
|
||||
```bash
|
||||
DJANGO_SETTINGS_MODULE=config.settings python manage.py test integration_tests
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Django will still create an isolated test database on the same MySQL server
|
||||
(for example `test_ai` when `DB_NAME=ai`).
|
||||
- External AI providers, remote sensing calls, and Celery workers are stubbed
|
||||
inside the tests so the suite stays deterministic while database writes stay
|
||||
real.
|
||||
- The tests in this folder use the full `config.urls` router, not the reduced
|
||||
`config.test_urls` router.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from django.test import TransactionTestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from location_data.models import NdviObservation, SoilLocation
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
|
||||
UNSET = object()
|
||||
|
||||
|
||||
def square_boundary(lat: float, lon: float, delta: float = 0.01) -> dict[str, Any]:
|
||||
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 IntegrationAPITestCase(TransactionTestCase):
|
||||
reset_sequences = True
|
||||
databases = {"default"}
|
||||
|
||||
primary_lat = 35.700000
|
||||
primary_lon = 51.400000
|
||||
forecast_start = date.today()
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.primary_boundary = square_boundary(self.primary_lat, self.primary_lon)
|
||||
self.primary_location = self.create_complete_location(
|
||||
lat=self.primary_lat,
|
||||
lon=self.primary_lon,
|
||||
boundary=self.primary_boundary,
|
||||
)
|
||||
self.seed_weather_forecasts(self.primary_location, start=self.forecast_start, days=7)
|
||||
self.seed_ndvi_observation(self.primary_location)
|
||||
|
||||
def create_complete_location(
|
||||
self,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
boundary: dict[str, Any] | None = None,
|
||||
) -> SoilLocation:
|
||||
location = SoilLocation.objects.create(
|
||||
latitude=f"{lat:.6f}",
|
||||
longitude=f"{lon:.6f}",
|
||||
farm_boundary=boundary or square_boundary(lat, lon),
|
||||
)
|
||||
return location
|
||||
|
||||
def seed_weather_forecasts(
|
||||
self,
|
||||
location: SoilLocation,
|
||||
*,
|
||||
start: date,
|
||||
days: int,
|
||||
temperature_base: float = 22.0,
|
||||
et0_base: float = 3.4,
|
||||
) -> list[WeatherForecast]:
|
||||
forecasts: list[WeatherForecast] = []
|
||||
for day_index in range(days):
|
||||
forecasts.append(
|
||||
WeatherForecast.objects.create(
|
||||
location=location,
|
||||
forecast_date=start + timedelta(days=day_index),
|
||||
temperature_min=12.0 + day_index,
|
||||
temperature_max=temperature_base + day_index,
|
||||
temperature_mean=17.0 + day_index,
|
||||
precipitation=1.2 if day_index % 3 == 0 else 0.0,
|
||||
precipitation_probability=35.0 + day_index,
|
||||
humidity_mean=48.0 + day_index,
|
||||
wind_speed_max=10.0 + day_index,
|
||||
et0=et0_base + (day_index * 0.2),
|
||||
weather_code=0 if day_index == 0 else 2,
|
||||
)
|
||||
)
|
||||
return forecasts
|
||||
|
||||
def seed_ndvi_observation(
|
||||
self,
|
||||
location: SoilLocation,
|
||||
*,
|
||||
observation_date: date | None = None,
|
||||
mean_ndvi: float = 0.73,
|
||||
) -> NdviObservation:
|
||||
return NdviObservation.objects.create(
|
||||
location=location,
|
||||
observation_date=observation_date or self.forecast_start,
|
||||
mean_ndvi=mean_ndvi,
|
||||
ndvi_map={"type": "FeatureCollection", "features": []},
|
||||
vegetation_health_class="Healthy",
|
||||
satellite_source="sentinel-2",
|
||||
metadata={"suite": "integration"},
|
||||
)
|
||||
|
||||
def create_irrigation_method_via_api(self, name: str, **overrides: Any) -> dict[str, Any]:
|
||||
payload = {
|
||||
"name": name,
|
||||
"category": "localized",
|
||||
"description": "Primary drip line for the farm",
|
||||
"water_efficiency_percent": 91.0,
|
||||
"water_pressure_required": "1.5 bar",
|
||||
"flow_rate": "4 l/h",
|
||||
"coverage_area": "row-based",
|
||||
"soil_type": "loam",
|
||||
"climate_suitability": "dry",
|
||||
}
|
||||
payload.update(overrides)
|
||||
response = self.client.post("/api/irrigation/", data=payload, format="json")
|
||||
self.assertEqual(response.status_code, 201, response.json())
|
||||
return response.json()["data"]
|
||||
|
||||
def create_plant_via_api(self, name: str, **overrides: Any) -> dict[str, Any]:
|
||||
payload = {
|
||||
"name": name,
|
||||
"light": "full sun",
|
||||
"watering": "every 2 days",
|
||||
"soil": "loamy",
|
||||
"temperature": "20-28C",
|
||||
"growth_stage": "vegetative",
|
||||
"planting_season": "spring",
|
||||
"harvest_time": "90 days",
|
||||
"spacing": "50 cm",
|
||||
"fertilizer": "balanced NPK",
|
||||
}
|
||||
payload.update(overrides)
|
||||
response = self.client.post("/api/plants/", data=payload, format="json")
|
||||
self.assertEqual(response.status_code, 201, response.json())
|
||||
return response.json()["data"]
|
||||
|
||||
def create_sensor_parameter_via_api(self, **overrides: Any) -> dict[str, Any]:
|
||||
payload = {
|
||||
"sensor_key": "sensor-7-1",
|
||||
"code": "soil_moisture",
|
||||
"name_fa": "soil moisture",
|
||||
"unit": "%",
|
||||
"data_type": "float",
|
||||
"metadata": {"min": 0, "max": 100},
|
||||
}
|
||||
payload.update(overrides)
|
||||
response = self.client.post("/api/farm-data/parameters/", data=payload, format="json")
|
||||
self.assertEqual(response.status_code, 201, response.json())
|
||||
return response.json()["data"]
|
||||
|
||||
def upsert_farm_via_api(
|
||||
self,
|
||||
*,
|
||||
farm_uuid: uuid.UUID,
|
||||
plant_ids: list[int] | None = None,
|
||||
irrigation_method_id: int | None | object = UNSET,
|
||||
sensor_payload: dict[str, Any] | None = None,
|
||||
boundary: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": boundary or self.primary_boundary,
|
||||
}
|
||||
if plant_ids is not None:
|
||||
payload["plant_ids"] = plant_ids
|
||||
if irrigation_method_id is not UNSET:
|
||||
payload["irrigation_method_id"] = irrigation_method_id
|
||||
if sensor_payload is not None:
|
||||
payload["sensor_payload"] = sensor_payload
|
||||
response = self.client.post("/api/farm-data/", data=payload, format="json")
|
||||
self.assertIn(response.status_code, {200, 201}, response.json())
|
||||
return response.json()["data"]
|
||||
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
from farm_data.models import ParameterUpdateLog, SensorData, SensorParameter
|
||||
from integration_tests.base import IntegrationAPITestCase
|
||||
from plant.models import Plant
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="config.urls")
|
||||
class FarmManagementJourneyTests(IntegrationAPITestCase):
|
||||
def test_full_management_journey_persists_farm_related_records(self) -> None:
|
||||
primary_method = self.create_irrigation_method_via_api("Drip Prime")
|
||||
backup_method = self.create_irrigation_method_via_api(
|
||||
"Sprinkler Backup",
|
||||
category="pressure",
|
||||
water_efficiency_percent=78.0,
|
||||
)
|
||||
|
||||
irrigation_list_response = self.client.get("/api/irrigation/")
|
||||
self.assertEqual(irrigation_list_response.status_code, 200)
|
||||
self.assertGreaterEqual(len(irrigation_list_response.json()["data"]), 2)
|
||||
irrigation_detail_response = self.client.get(f"/api/irrigation/{primary_method['id']}/")
|
||||
self.assertEqual(irrigation_detail_response.status_code, 200)
|
||||
self.assertEqual(irrigation_detail_response.json()["data"]["name"], "Drip Prime")
|
||||
|
||||
moisture_parameter = self.create_sensor_parameter_via_api()
|
||||
self.assertEqual(moisture_parameter["action"], ParameterUpdateLog.ACTION_ADDED)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
|
||||
)
|
||||
|
||||
moisture_parameter_update = self.create_sensor_parameter_via_api(
|
||||
metadata={"min": 5, "max": 85, "ui": "gauge"},
|
||||
)
|
||||
self.assertEqual(moisture_parameter_update["action"], ParameterUpdateLog.ACTION_MODIFIED)
|
||||
self.assertEqual(
|
||||
ParameterUpdateLog.objects.filter(parameter__code="soil_moisture").count(),
|
||||
2,
|
||||
)
|
||||
|
||||
tomato = self.create_plant_via_api("Tomato")
|
||||
cucumber = self.create_plant_via_api("Cucumber", watering="daily")
|
||||
removable_plant = self.create_plant_via_api("Remove Plant")
|
||||
|
||||
plants_list_response = self.client.get("/api/plants/")
|
||||
self.assertEqual(plants_list_response.status_code, 200)
|
||||
returned_names = {item["name"] for item in plants_list_response.json()["data"]}
|
||||
self.assertTrue({"Tomato", "Cucumber", "Remove Plant"}.issubset(returned_names))
|
||||
|
||||
plant_catalog = self.create_plant_via_api(
|
||||
"Pepper",
|
||||
growth_stage="",
|
||||
icon="sprout",
|
||||
)
|
||||
Plant.objects.filter(pk=plant_catalog["id"]).update(growth_stage="", icon="")
|
||||
plant_names_response = self.client.get("/api/plants/names/")
|
||||
self.assertEqual(plant_names_response.status_code, 200)
|
||||
plant_names_payload = {
|
||||
item["name"]: item for item in plant_names_response.json()["data"]
|
||||
}
|
||||
self.assertEqual(plant_names_payload["Pepper"]["icon"], "leaf")
|
||||
self.assertEqual(
|
||||
plant_names_payload["Pepper"]["growth_stages"],
|
||||
["initial", "vegetative", "flowering", "fruiting", "maturity"],
|
||||
)
|
||||
pepper = Plant.objects.get(pk=plant_catalog["id"])
|
||||
self.assertEqual(
|
||||
pepper.growth_stage,
|
||||
"initial, vegetative, flowering, fruiting, maturity",
|
||||
)
|
||||
self.assertEqual(pepper.icon, "leaf")
|
||||
|
||||
plant_patch_response = self.client.patch(
|
||||
f"/api/plants/{tomato['id']}/",
|
||||
data={"growth_stage": "flowering", "watering": "daily"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(plant_patch_response.status_code, 200)
|
||||
self.assertEqual(Plant.objects.get(pk=tomato["id"]).growth_stage, "flowering")
|
||||
|
||||
plant_put_response = self.client.put(
|
||||
f"/api/plants/{cucumber['id']}/",
|
||||
data={
|
||||
"name": "Cucumber",
|
||||
"light": "full sun",
|
||||
"watering": "every day",
|
||||
"soil": "sandy loam",
|
||||
"temperature": "18-30C",
|
||||
"growth_stage": "fruiting",
|
||||
"planting_season": "spring",
|
||||
"harvest_time": "70 days",
|
||||
"spacing": "40 cm",
|
||||
"fertilizer": "potassium rich",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(plant_put_response.status_code, 200)
|
||||
|
||||
with patch(
|
||||
"plant.views.fetch_plant_info_from_api",
|
||||
return_value={
|
||||
"name": "Tomato",
|
||||
"light": "full sun",
|
||||
"watering": "daily",
|
||||
"soil": "loamy",
|
||||
"temperature": "20-28C",
|
||||
"growth_stage": "flowering",
|
||||
"planting_season": "spring",
|
||||
"harvest_time": "90 days",
|
||||
"spacing": "50 cm",
|
||||
"fertilizer": "balanced NPK",
|
||||
},
|
||||
):
|
||||
plant_fetch_response = self.client.post(
|
||||
"/api/plants/fetch-info/",
|
||||
data={"name": "Tomato"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(plant_fetch_response.status_code, 200)
|
||||
self.assertEqual(plant_fetch_response.json()["data"]["name"], "Tomato")
|
||||
|
||||
plant_delete_response = self.client.delete(f"/api/plants/{removable_plant['id']}/")
|
||||
self.assertEqual(plant_delete_response.status_code, 200)
|
||||
self.assertFalse(Plant.objects.filter(pk=removable_plant["id"]).exists())
|
||||
|
||||
farm_uuid = uuid.uuid4()
|
||||
created_farm = self.upsert_farm_via_api(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_ids=[tomato["id"], cucumber["id"]],
|
||||
irrigation_method_id=primary_method["id"],
|
||||
sensor_payload={
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 41.2,
|
||||
"soil_temperature": 23.4,
|
||||
"soil_ph": 6.8,
|
||||
"electrical_conductivity": 1.1,
|
||||
"nitrogen": 17.0,
|
||||
"phosphorus": 12.5,
|
||||
"potassium": 21.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(created_farm["farm_uuid"], str(farm_uuid))
|
||||
farm_record = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertCountEqual(
|
||||
list(farm_record.plants.values_list("id", flat=True)),
|
||||
[tomato["id"], cucumber["id"]],
|
||||
)
|
||||
self.assertEqual(farm_record.irrigation_method_id, primary_method["id"])
|
||||
self.assertEqual(farm_record.center_location_id, self.primary_location.id)
|
||||
|
||||
updated_farm = self.upsert_farm_via_api(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_ids=[tomato["id"]],
|
||||
irrigation_method_id=backup_method["id"],
|
||||
sensor_payload={
|
||||
"sensor-7-1": {
|
||||
"nitrogen": 19.5,
|
||||
"soil_moisture": 44.0,
|
||||
},
|
||||
"leaf-sensor": {
|
||||
"leaf_wetness": 11.0,
|
||||
"leaf_temperature": 21.3,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(updated_farm["irrigation_method_id"], backup_method["id"])
|
||||
|
||||
farm_record.refresh_from_db()
|
||||
self.assertEqual(farm_record.irrigation_method_id, backup_method["id"])
|
||||
self.assertCountEqual(list(farm_record.plants.values_list("id", flat=True)), [tomato["id"]])
|
||||
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_temperature"], 23.4)
|
||||
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_moisture"], 44.0)
|
||||
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["nitrogen"], 19.5)
|
||||
self.assertEqual(farm_record.sensor_payload["leaf-sensor"]["leaf_wetness"], 11.0)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists()
|
||||
)
|
||||
|
||||
farm_detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/")
|
||||
self.assertEqual(farm_detail_response.status_code, 200)
|
||||
farm_detail = farm_detail_response.json()["data"]
|
||||
self.assertEqual(farm_detail["center_location"]["id"], self.primary_location.id)
|
||||
self.assertEqual(farm_detail["irrigation_method_id"], backup_method["id"])
|
||||
self.assertEqual(farm_detail["plant_ids"], [tomato["id"]])
|
||||
self.assertEqual(farm_detail["plants"][0]["name"], "Tomato")
|
||||
self.assertEqual(
|
||||
farm_detail["sensor_payload"]["leaf-sensor"]["leaf_temperature"],
|
||||
21.3,
|
||||
)
|
||||
self.assertCountEqual(
|
||||
[item["code"] for item in farm_detail["sensor_schema"]["leaf-sensor"]],
|
||||
["leaf_temperature", "leaf_wetness"],
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import uuid
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import override_settings
|
||||
|
||||
from crop_simulation.models import SimulationRun, SimulationScenario
|
||||
from farm_alerts.models import FarmAlertNotification
|
||||
from farm_data.models import SensorData
|
||||
from integration_tests.base import IntegrationAPITestCase, square_boundary
|
||||
|
||||
|
||||
class FakeAsyncResult:
|
||||
def __init__(self, *, state: str, result=None, info=None):
|
||||
self.state = state
|
||||
self.result = result
|
||||
self.info = info
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="config.urls")
|
||||
class ReportingAndAiJourneyTests(IntegrationAPITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.irrigation_method = self.create_irrigation_method_via_api("Analytics Drip")
|
||||
self.primary_plant = self.create_plant_via_api("Tomato")
|
||||
self.secondary_plant = self.create_plant_via_api("Pepper")
|
||||
self.farm_uuid = uuid.uuid4()
|
||||
self.upsert_farm_via_api(
|
||||
farm_uuid=self.farm_uuid,
|
||||
plant_ids=[self.primary_plant["id"], self.secondary_plant["id"]],
|
||||
irrigation_method_id=self.irrigation_method["id"],
|
||||
sensor_payload={
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 46.0,
|
||||
"soil_temperature": 24.2,
|
||||
"soil_ph": 6.5,
|
||||
"electrical_conductivity": 1.3,
|
||||
"nitrogen": 20.0,
|
||||
"phosphorus": 11.0,
|
||||
"potassium": 18.0,
|
||||
"timestamp": "2026-04-10T06:30:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.seed_neighbor_farm()
|
||||
|
||||
def seed_neighbor_farm(self) -> None:
|
||||
neighbor_location = self.create_complete_location(
|
||||
lat=35.706000,
|
||||
lon=51.406000,
|
||||
boundary=square_boundary(35.706000, 51.406000, delta=0.008),
|
||||
clay_values=(19.0, 16.5, 13.0),
|
||||
nitrogen_values=(12.0, 10.0, 7.0),
|
||||
)
|
||||
self.seed_weather_forecasts(
|
||||
neighbor_location,
|
||||
start=self.forecast_start,
|
||||
days=7,
|
||||
temperature_base=24.0,
|
||||
et0_base=3.8,
|
||||
)
|
||||
neighbor_sensor = SensorData.objects.create(
|
||||
farm_uuid=uuid.uuid4(),
|
||||
center_location=neighbor_location,
|
||||
weather_forecast=neighbor_location.weather_forecasts.order_by("forecast_date").first(),
|
||||
irrigation_method_id=self.irrigation_method["id"],
|
||||
sensor_payload={
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 38.5,
|
||||
"soil_temperature": 25.0,
|
||||
"soil_ph": 6.7,
|
||||
"electrical_conductivity": 1.0,
|
||||
"nitrogen": 16.0,
|
||||
"timestamp": "2026-04-10T06:35:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
neighbor_sensor.plants.set([self.primary_plant["id"]])
|
||||
|
||||
def test_reporting_endpoints_read_from_persisted_farm_context(self) -> None:
|
||||
soil_response = self.client.get(
|
||||
"/api/soil-data/",
|
||||
data={"lat": f"{self.primary_lat:.6f}", "lon": f"{self.primary_lon:.6f}"},
|
||||
)
|
||||
self.assertEqual(soil_response.status_code, 200)
|
||||
self.assertEqual(soil_response.json()["data"]["source"], "database")
|
||||
self.assertIn("satellite_snapshots", soil_response.json()["data"])
|
||||
|
||||
weather_response = self.client.post(
|
||||
"/api/weather/farm-card/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(weather_response.status_code, 200)
|
||||
self.assertEqual(weather_response.json()["data"]["condition"], "صاف")
|
||||
|
||||
with patch(
|
||||
"weather.water_need_prediction.get_water_need_prediction_insight",
|
||||
return_value={
|
||||
"summary": "Water demand is moderate for the next week.",
|
||||
"irrigation_outlook": "Increase slowly.",
|
||||
"recommended_action": "Keep early morning irrigation.",
|
||||
"risk_note": "Watch evapotranspiration after day 4.",
|
||||
"confidence": 0.88,
|
||||
"knowledge_base": "water_need_prediction",
|
||||
"tone_file": "config/tones/water_need_prediction_tone.txt",
|
||||
"raw_response": "{\"summary\": \"ok\"}",
|
||||
},
|
||||
):
|
||||
water_need_response = self.client.post(
|
||||
"/api/weather/water-need-prediction/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(water_need_response.status_code, 200)
|
||||
self.assertGreater(water_need_response.json()["data"]["totalNext7Days"], 0)
|
||||
self.assertEqual(
|
||||
water_need_response.json()["data"]["knowledge_base"],
|
||||
"water_need_prediction",
|
||||
)
|
||||
|
||||
economy_response = self.client.post(
|
||||
"/api/economy/overview/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(economy_response.status_code, 200)
|
||||
self.assertEqual(economy_response.json()["data"]["farm_uuid"], str(self.farm_uuid))
|
||||
|
||||
heatmap_response = self.client.post(
|
||||
"/api/soile/moisture-heatmap/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(heatmap_response.status_code, 200)
|
||||
self.assertGreater(len(heatmap_response.json()["data"]["grid_cells"]), 0)
|
||||
self.assertGreaterEqual(len(heatmap_response.json()["data"]["sensor_points"]), 1)
|
||||
|
||||
soil_health_response = self.client.post(
|
||||
"/api/soile/health-summary/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(soil_health_response.status_code, 200)
|
||||
self.assertIn("healthScore", soil_health_response.json()["data"])
|
||||
|
||||
with patch(
|
||||
"soile.services.get_soil_anomaly_insight",
|
||||
return_value={
|
||||
"interpretation": {
|
||||
"summary": "No critical anomaly detected.",
|
||||
"recommended_action": "Continue monitoring.",
|
||||
},
|
||||
"knowledge_base": "soil_anomaly",
|
||||
"raw_response": "{\"status\": \"ok\"}",
|
||||
},
|
||||
):
|
||||
anomaly_response = self.client.post(
|
||||
"/api/soile/anomaly-detection/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(anomaly_response.status_code, 200)
|
||||
self.assertEqual(anomaly_response.json()["data"]["knowledge_base"], "soil_anomaly")
|
||||
|
||||
ndvi_response = self.client.post(
|
||||
"/api/soil-data/ndvi-health/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(ndvi_response.status_code, 200)
|
||||
self.assertEqual(ndvi_response.json()["data"]["vegetation_health_class"], "Healthy")
|
||||
|
||||
broken_simulation_service = SimpleNamespace(
|
||||
get_water_stress=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulation offline"))
|
||||
)
|
||||
crop_simulation_app = apps.get_app_config("crop_simulation")
|
||||
with patch.object(
|
||||
crop_simulation_app,
|
||||
"get_water_stress_service",
|
||||
return_value=broken_simulation_service,
|
||||
):
|
||||
water_stress_response = self.client.post(
|
||||
"/api/irrigation/water-stress/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(water_stress_response.status_code, 500)
|
||||
|
||||
def test_ai_assistant_and_recommendation_endpoints_use_farm_context(self) -> None:
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("rag.config.load_rag_config", return_value=SimpleNamespace())
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"rag.views.chat_rag_stream",
|
||||
return_value=iter(["Farm looks stable. ", "Moisture is acceptable."]),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"rag.services.irrigation.get_irrigation_recommendation",
|
||||
return_value={
|
||||
"sections": [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": "برنامه آبیاری بهینه",
|
||||
"content": "هفته ای 3 نوبت آبیاری انجام شود.",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"rag.services.fertilization.get_fertilization_recommendation",
|
||||
return_value={
|
||||
"sections": [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": "برنامه کودهی بهینه",
|
||||
"fertilizerType": "15-5-30",
|
||||
"amount": "60 kg",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"pest_disease.views.get_pest_disease_detection",
|
||||
return_value={
|
||||
"diagnosis": "low risk leaf stress",
|
||||
"confidence": 0.81,
|
||||
},
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"pest_disease.views.get_pest_disease_risk",
|
||||
return_value={
|
||||
"riskLevel": "medium",
|
||||
"topRisk": "powdery mildew",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
chat_response = self.client.post(
|
||||
"/api/rag/chat/",
|
||||
data={
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"query": "Give me a short farm update",
|
||||
"history": [],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(chat_response.status_code, 200)
|
||||
streamed_text = b"".join(chat_response.streaming_content).decode("utf-8")
|
||||
self.assertIn("Moisture is acceptable", streamed_text)
|
||||
|
||||
irrigation_recommend_response = self.client.post(
|
||||
"/api/irrigation/recommend/",
|
||||
data={
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"plant_name": "Tomato",
|
||||
"growth_stage": "flowering",
|
||||
"irrigation_method_name": "Analytics Drip",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(irrigation_recommend_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
irrigation_recommend_response.json()["data"]["sections"][0]["type"],
|
||||
"recommendation",
|
||||
)
|
||||
|
||||
fertilization_recommend_response = self.client.post(
|
||||
"/api/fertilization/recommend/",
|
||||
data={
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"plant_name": "Tomato",
|
||||
"growth_stage": "flowering",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(fertilization_recommend_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
fertilization_recommend_response.json()["data"]["sections"][0]["fertilizerType"],
|
||||
"15-5-30",
|
||||
)
|
||||
|
||||
pest_detect_response = self.client.post(
|
||||
"/api/pest-disease/detect/",
|
||||
data={
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"plant_name": "Tomato",
|
||||
"query": "Check leaf condition",
|
||||
"image_urls": ["https://example.com/leaf.jpg"],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(pest_detect_response.status_code, 200)
|
||||
self.assertEqual(pest_detect_response.json()["data"]["confidence"], 0.81)
|
||||
|
||||
pest_risk_response = self.client.post(
|
||||
"/api/pest-disease/risk/",
|
||||
data={
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"plant_name": "Tomato",
|
||||
"growth_stage": "flowering",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(pest_risk_response.status_code, 200)
|
||||
|
||||
def test_alert_and_crop_simulation_endpoints_persist_records(self) -> None:
|
||||
def tracker_stub(*, farm_uuid: str, query: str | None = None):
|
||||
from farm_alerts.services import _save_notifications, _serialize_notification
|
||||
|
||||
saved = _save_notifications(
|
||||
farm_uuid=str(farm_uuid),
|
||||
endpoint=FarmAlertNotification.ENDPOINT_TRACKER,
|
||||
notifications=[
|
||||
{
|
||||
"level": FarmAlertNotification.LEVEL_WARNING,
|
||||
"title": "Moisture drift",
|
||||
"message": "Moisture dropped below the target band.",
|
||||
"suggested_action": "Review the next irrigation cycle.",
|
||||
"source_alert_id": "soil-moisture:1",
|
||||
"source_metric_type": "soil_moisture",
|
||||
}
|
||||
],
|
||||
)
|
||||
return {
|
||||
"headline": "Tracker result",
|
||||
"overview": "One warning was recorded.",
|
||||
"status_level": "warning",
|
||||
"query": query,
|
||||
"notifications": [_serialize_notification(item) for item in saved],
|
||||
}
|
||||
|
||||
def timeline_stub(*, farm_uuid: str, query: str | None = None):
|
||||
from farm_alerts.services import _save_notifications, _serialize_notification
|
||||
|
||||
saved = _save_notifications(
|
||||
farm_uuid=str(farm_uuid),
|
||||
endpoint=FarmAlertNotification.ENDPOINT_TIMELINE,
|
||||
notifications=[
|
||||
{
|
||||
"level": FarmAlertNotification.LEVEL_INFO,
|
||||
"title": "Irrigation completed",
|
||||
"message": "The latest drip cycle finished successfully.",
|
||||
"suggested_action": "Recheck moisture after sunrise.",
|
||||
"source_alert_id": "irrigation:1",
|
||||
"source_metric_type": "irrigation",
|
||||
}
|
||||
],
|
||||
)
|
||||
return {
|
||||
"headline": "Timeline result",
|
||||
"overview": "One event was stored.",
|
||||
"query": query,
|
||||
"timeline": [
|
||||
{
|
||||
"timestamp": "2026-04-10T05:30:00Z",
|
||||
"level": "info",
|
||||
"title": "Irrigation completed",
|
||||
"description": "Cycle finished.",
|
||||
"source_alert_id": "irrigation:1",
|
||||
"source_metric_type": "irrigation",
|
||||
}
|
||||
],
|
||||
"notifications": [_serialize_notification(item) for item in saved],
|
||||
}
|
||||
|
||||
with patch("farm_alerts.views.get_farm_alerts_tracker", side_effect=tracker_stub):
|
||||
tracker_response = self.client.post(
|
||||
"/api/farm-alerts/tracker/",
|
||||
data={"farm_uuid": str(self.farm_uuid), "query": "status"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(tracker_response.status_code, 200)
|
||||
|
||||
with patch("farm_alerts.views.get_farm_alerts_timeline", side_effect=timeline_stub):
|
||||
timeline_response = self.client.post(
|
||||
"/api/farm-alerts/timeline/",
|
||||
data={"farm_uuid": str(self.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(timeline_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
FarmAlertNotification.objects.filter(farm_uuid=self.farm_uuid).count(),
|
||||
2,
|
||||
)
|
||||
|
||||
current_chart_service = SimpleNamespace(
|
||||
simulate=lambda **_kwargs: {
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"plant_name": "Tomato",
|
||||
"engine": "stub",
|
||||
"model_name": "integration-model",
|
||||
"scenario_id": None,
|
||||
"simulation_warning": "",
|
||||
"categories": ["day1", "day2"],
|
||||
"series": [{"name": "LAI", "data": [0.8, 1.1]}],
|
||||
"summary": {"expectedTrend": "up"},
|
||||
"current_state": {"soilMoisture": 46.0},
|
||||
"metrics": {"yieldEstimate": 8.2},
|
||||
"daily_output": [{"day": "2026-04-10", "lai": 0.8}],
|
||||
}
|
||||
)
|
||||
harvest_service = SimpleNamespace(
|
||||
get_harvest_prediction=lambda **_kwargs: {
|
||||
"date": "2026-07-15",
|
||||
"dateFormatted": "15 Jul 2026",
|
||||
"daysUntil": 96,
|
||||
"description": "Expected harvest window",
|
||||
"optimalWindowStart": "2026-07-10",
|
||||
"optimalWindowEnd": "2026-07-20",
|
||||
"gddDetails": {"current": 420, "target": 1220},
|
||||
}
|
||||
)
|
||||
yield_service = SimpleNamespace(
|
||||
get_yield_prediction=lambda **_kwargs: {
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"plant_name": "Tomato",
|
||||
"predictedYieldTons": 8.4,
|
||||
"predictedYieldRaw": 8400.0,
|
||||
"unit": "t/ha",
|
||||
"sourceUnit": "kg/ha",
|
||||
"simulationEngine": "stub",
|
||||
"simulationModel": "integration-model",
|
||||
"scenarioId": None,
|
||||
"simulationWarning": "",
|
||||
"supportingMetrics": {"biomass": 12.1},
|
||||
}
|
||||
)
|
||||
crop_simulation_app = apps.get_app_config("crop_simulation")
|
||||
with (
|
||||
patch.object(
|
||||
crop_simulation_app,
|
||||
"get_current_farm_chart_simulator",
|
||||
return_value=current_chart_service,
|
||||
),
|
||||
patch.object(
|
||||
crop_simulation_app,
|
||||
"get_harvest_prediction_service",
|
||||
return_value=harvest_service,
|
||||
),
|
||||
patch.object(
|
||||
crop_simulation_app,
|
||||
"get_yield_prediction_service",
|
||||
return_value=yield_service,
|
||||
),
|
||||
):
|
||||
current_chart_response = self.client.post(
|
||||
"/api/crop-simulation/current-farm-chart/",
|
||||
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
|
||||
format="json",
|
||||
)
|
||||
harvest_response = self.client.post(
|
||||
"/api/crop-simulation/harvest-prediction/",
|
||||
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
|
||||
format="json",
|
||||
)
|
||||
yield_response = self.client.post(
|
||||
"/api/crop-simulation/yield-prediction/",
|
||||
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(current_chart_response.status_code, 200)
|
||||
self.assertEqual(harvest_response.status_code, 200)
|
||||
self.assertEqual(yield_response.status_code, 200)
|
||||
|
||||
task_state: dict[str, object] = {}
|
||||
|
||||
def growth_delay_stub(payload):
|
||||
serializable_payload = {
|
||||
**payload,
|
||||
"farm_uuid": str(payload["farm_uuid"]) if payload.get("farm_uuid") else None,
|
||||
}
|
||||
scenario = SimulationScenario.objects.create(
|
||||
name=f"integration-{payload['plant_name']}",
|
||||
scenario_type=SimulationScenario.ScenarioType.SINGLE,
|
||||
status=SimulationScenario.Status.SUCCESS,
|
||||
input_payload=serializable_payload,
|
||||
result_payload={"engine": "stub"},
|
||||
)
|
||||
SimulationRun.objects.create(
|
||||
scenario=scenario,
|
||||
run_key="primary",
|
||||
label=payload["plant_name"],
|
||||
status=SimulationScenario.Status.SUCCESS,
|
||||
weather_payload=payload.get("weather", []),
|
||||
soil_payload=payload.get("soil_parameters", {}),
|
||||
result_payload={"summary": "ok"},
|
||||
)
|
||||
task_id = f"growth-task-{scenario.id}"
|
||||
task_state["task_id"] = task_id
|
||||
task_state["result"] = {
|
||||
"plant_name": payload["plant_name"],
|
||||
"dynamic_parameters": payload["dynamic_parameters"],
|
||||
"engine": "stub",
|
||||
"model_name": "integration-model",
|
||||
"scenario_id": scenario.id,
|
||||
"simulation_warning": "",
|
||||
"summary_metrics": {"yield_estimate": 8.4},
|
||||
"stage_timeline": [
|
||||
{
|
||||
"order": 1,
|
||||
"stage_code": "VEG",
|
||||
"stage_name": "Vegetative",
|
||||
"start_date": "2026-04-10",
|
||||
"end_date": "2026-05-05",
|
||||
"days_count": 25,
|
||||
"metrics": {"LAI": {"start": 0.8, "end": 1.5, "min": 0.8, "max": 1.5, "avg": 1.15}},
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"stage_code": "FLOW",
|
||||
"stage_name": "Flowering",
|
||||
"start_date": "2026-05-06",
|
||||
"end_date": "2026-06-01",
|
||||
"days_count": 26,
|
||||
"metrics": {"LAI": {"start": 1.6, "end": 2.2, "min": 1.6, "max": 2.2, "avg": 1.9}},
|
||||
},
|
||||
],
|
||||
"daily_records_count": 51,
|
||||
"default_page_size": payload.get("page_size", 2),
|
||||
}
|
||||
return SimpleNamespace(id=task_id)
|
||||
|
||||
with patch("crop_simulation.views.run_growth_simulation_task.delay", side_effect=growth_delay_stub):
|
||||
growth_response = self.client.post(
|
||||
"/api/crop-simulation/growth/",
|
||||
data={
|
||||
"plant_name": "Tomato",
|
||||
"dynamic_parameters": ["DVS", "LAI"],
|
||||
"farm_uuid": str(self.farm_uuid),
|
||||
"page_size": 1,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(growth_response.status_code, 202)
|
||||
|
||||
with patch(
|
||||
"crop_simulation.views._get_async_result",
|
||||
return_value=FakeAsyncResult(state="SUCCESS", result=task_state["result"]),
|
||||
):
|
||||
growth_status_response = self.client.get(
|
||||
f"/api/crop-simulation/growth/{task_state['task_id']}/status/?page=1&page_size=1"
|
||||
)
|
||||
self.assertEqual(growth_status_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
SimulationScenario.objects.filter(name="integration-Tomato").count(),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(SimulationRun.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
growth_status_response.json()["data"]["result"]["pagination"]["page_size"],
|
||||
1,
|
||||
)
|
||||
Reference in New Issue
Block a user