This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+19
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
+181
View File
@@ -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,
)