from __future__ import annotations from datetime import timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch from django.test import TestCase, override_settings from django.utils import timezone from rest_framework.test import APIClient from rag.failure_contract import RAGServiceError from soile.services import SoilMoistureHeatmapService @override_settings(ROOT_URLCONF="soile.urls") class SoilMoistureHeatmapApiTests(TestCase): def setUp(self): self.client = APIClient() @patch("soile.views.apps.get_app_config") def test_heatmap_api_returns_payload(self, mock_get_app_config): mock_service = SimpleNamespace( get_heatmap=lambda **_kwargs: { "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "location": {"lat": 35.7, "lon": 51.4}, "current_sensor": {"soil_moisture": 22.5}, "soil_profile": [{"depth_label": "0-5cm", "field_capacity": 0.34}], "timestamp": "2026-04-01T00:00:00", "grid_resolution": {"lat_step": 0.001, "lon_step": 0.001, "rows": 2, "cols": 2}, "grid_cells": [{"lat": 35.7, "lon": 51.4, "moisture_value": 22.5, "quality_flag": "REAL"}], "sensor_points": [{"sensor_id": "farm-1", "soil_moisture_value": 22.5}], "quality_legend": {"REAL": "اندازه گیری واقعی سنسور"}, } ) mock_get_app_config.return_value = SimpleNamespace( get_soil_moisture_service=lambda: mock_service ) response = self.client.post( "/moisture-heatmap/", data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, format="json", ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") self.assertEqual(payload["current_sensor"]["soil_moisture"], 22.5) @patch("soile.views.apps.get_app_config") def test_heatmap_api_returns_404_for_missing_farm(self, mock_get_app_config): mock_service = SimpleNamespace( get_heatmap=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) ) mock_get_app_config.return_value = SimpleNamespace( get_soil_moisture_service=lambda: mock_service ) response = self.client.post( "/moisture-heatmap/", data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, format="json", ) self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "Farm not found.") @override_settings(ROOT_URLCONF="soile.urls") class SoilAnomalyDetectionApiTests(TestCase): def setUp(self): self.client = APIClient() @patch("soile.views.apps.get_app_config") def test_anomaly_api_returns_payload(self, mock_get_app_config): mock_service = SimpleNamespace( get_anomaly_detection=lambda **_kwargs: { "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "generated_at": "2026-04-01T00:00:00", "anomalies": [ { "metric_type": "soil_moisture", "label": "رطوبت خاک", "severity": "high", "observed_value": 21.4, } ], "interpretation": { "summary": "ناهنجاري در رطوبت خاک شناسايي شد.", "explanation": "رطوبت خاک از الگوي معمول فاصله گرفته است.", "likely_cause": "احتمال اختلال در آبياري يا افزايش تبخير.", "recommended_action": "آبياري و قرائت سنسور بازبيني شود.", "monitoring_priority": "urgent", "confidence": 0.84, }, "raw_response": "{\"summary\":\"ok\"}", } ) mock_get_app_config.return_value = SimpleNamespace( get_soil_anomaly_service=lambda: mock_service ) response = self.client.post( "/anomaly-detection/", data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, format="json", ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") self.assertEqual(payload["interpretation"]["monitoring_priority"], "urgent") @patch("soile.views.apps.get_app_config") def test_anomaly_api_returns_404_for_missing_farm(self, mock_get_app_config): mock_service = SimpleNamespace( get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) ) mock_get_app_config.return_value = SimpleNamespace( get_soil_anomaly_service=lambda: mock_service ) response = self.client.post( "/anomaly-detection/", data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, format="json", ) self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "Farm not found.") @patch("soile.views.apps.get_app_config") def test_anomaly_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config): mock_service = SimpleNamespace( get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw( RAGServiceError( error_code="invalid_json", message="Soil anomaly LLM response was not valid JSON.", source="llm", retriable=True, http_status=502, ) ) ) mock_get_app_config.return_value = SimpleNamespace( get_soil_anomaly_service=lambda: mock_service ) response = self.client.post( "/anomaly-detection/", data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, format="json", ) self.assertEqual(response.status_code, 502) self.assertEqual(response.json()["data"]["error_code"], "invalid_json") class SoilMoistureHeatmapServiceTests(TestCase): @patch("soile.services.SensorData.objects") def test_heatmap_service_builds_boundary_aware_weighted_output(self, mock_objects): now = timezone.now() boundary = { "type": "Polygon", "coordinates": [ [ [51.39, 35.70], [51.41, 35.70], [51.41, 35.72], [51.39, 35.72], [51.39, 35.70], ] ], } depth = SimpleNamespace( depth_label="0-5cm", wv0033=0.34, wv1500=0.14, wv0010=0.40, nitrogen=12.0, phh2o=7.1, sand=40.0, silt=35.0, clay=25.0, ) plants = SimpleNamespace(values_list=lambda *args, **kwargs: [1]) center_a = SimpleNamespace(latitude=35.70, longitude=51.39, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth])) center_b = SimpleNamespace(latitude=35.72, longitude=51.41, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth])) sensor_a = SimpleNamespace( farm_uuid="farm-a", center_location=center_a, plants=plants, sensor_payload={"sensor-1": {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}}, updated_at=now - timedelta(hours=2), get_sensor_block=lambda: {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}, soil_moisture=20.0, soil_temperature=18.0, soil_ph=7.0, electrical_conductivity=1.2, nitrogen=10.0, phosphorus=8.0, potassium=12.0, ) sensor_b = SimpleNamespace( farm_uuid="farm-b", center_location=center_b, plants=plants, sensor_payload={"sensor-1": {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}}, updated_at=now - timedelta(hours=30), get_sensor_block=lambda: {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}, soil_moisture=36.0, soil_temperature=19.0, soil_ph=7.2, electrical_conductivity=1.3, nitrogen=11.0, phosphorus=8.5, potassium=12.5, ) current_first = MagicMock() current_first.first.return_value = sensor_a current_filter = MagicMock() current_filter.filter.return_value = current_first current_qs = MagicMock() current_qs.prefetch_related.return_value = current_filter network_distinct = MagicMock() network_distinct.distinct.return_value = [sensor_a, sensor_b] network_filter = MagicMock() network_filter.filter.return_value = network_distinct network_qs = MagicMock() network_qs.prefetch_related.return_value = network_filter mock_objects.select_related.side_effect = [current_qs, network_qs] payload = SoilMoistureHeatmapService().get_heatmap(farm_uuid="farm-a") self.assertEqual(payload["model_metadata"]["interpolation_model"], "boundary_aware_weighted_idw") self.assertTrue(payload["model_metadata"]["uses_freshness_weighting"]) self.assertTrue(payload["model_metadata"]["uses_boundary_mask"]) self.assertEqual(payload["summary"]["active_sensor_count"], 2) self.assertEqual(payload["depth_layers"][0]["depth_label"], "0-5cm") self.assertGreater(payload["sensor_points"][0]["reliability_score"], payload["sensor_points"][1]["reliability_score"]) outside_cells = [cell for cell in payload["grid_cells"] if not cell["inside_farm_boundary"]] self.assertTrue(outside_cells) self.assertTrue(all(cell["moisture_value"] is None for cell in outside_cells)) self.assertIn("uncertainty", payload["grid_cells"][0]) @override_settings(ROOT_URLCONF="soile.urls") class SoilHealthSummaryApiTests(TestCase): def setUp(self): self.client = APIClient() @patch("soile.views.apps.get_app_config") def test_health_summary_api_returns_payload(self, mock_get_app_config): mock_service = SimpleNamespace( get_health_summary=lambda **_kwargs: { "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "healthScore": 82, "profileSource": "گوجه فرنگی", "healthScoreDetails": {"components": []}, "healthLanguage": {"short_chip_text": "پایدار"}, "avgSoilMoisture": 46, "avgSoilMoistureRaw": 45.8, "avgSoilMoistureStatus": "بهینه", } ) mock_get_app_config.return_value = SimpleNamespace( get_soil_health_service=lambda: mock_service ) response = self.client.post( "/health-summary/", data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, format="json", ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["healthScore"], 82) self.assertEqual(payload["avgSoilMoistureStatus"], "بهینه")