import httpx from django.test import TestCase from rest_framework.test import APIClient from apps.accounts.models import User from apps.farms.models import Cycle, Flock, Kandang, Site from apps.operations.models import AIInsight, FeedSacks, IotPanel, KPI from apps.sync.models import ActiveSite from apps.sync.services.farm_export_client import FarmExportClient from apps.sync.services.mirror_pull import sync_active_site_safe from datetime import date from unittest.mock import patch def _empty_series(page=1, page_size=500): block = {"count": 0, "page": page, "page_size": page_size, "results": []} return { "kpis": dict(block), "manual_inputs": dict(block), "feed_sacks": dict(block), "chicken_countings": dict(block), "chicken_weights": dict(block), "ai_insights": dict(block), } class MirrorPullTests(TestCase): def setUp(self): self.gm = User.objects.create_user( user_name="gm-mirror", password="x", status=User.STATUS_GM ) self.active = ActiveSite.objects.create( code="sukawarna", name="Sukawarna", source_site_id="sukawarna-0002", api_base_url="https://farm.example/api/v1", api_key="site-secret", managed_by=self.gm, is_active=True, ) self.start = date(2026, 9, 1) def _handler(self): def handler(request: httpx.Request) -> httpx.Response: path = request.url.path params = request.url.params if path.endswith("/pusat/export/iot/"): body = { "sites": [ { "site_id": "sukawarna-0002", "site_name": "Sukawarna", "kandangs": [ { "kandang_id": 101, "kandang_name": "K1", "flocks": [ {"flock_id": 201, "flock_name": "Lantai 1"} ], } ], } ], "data": { "iot_panels": { "count": 1, "page": 1, "page_size": 500, "results": [ { "flock_id": 201, "date": "2026-09-02", "timestamp": "2026-09-02T10:00:00+07:00", "wind_speed": 1.2, "humidity": 70.0, "water_total": 10.0, "average_temperature": 28.0, "experience_temperature": 27.0, } ], } }, } return httpx.Response(200, json=body) cycle_id = params.get("cycle_id") if cycle_id: series = _empty_series() series["feed_sacks"] = { "count": 1, "page": 1, "page_size": 500, "results": [ { "date": "2026-09-02", "in_today": 5, "out_today": 0, "in_total": 5, "out_total": 0, "feed_use_today": 2, "feed_use_total": 2, "cycle": int(cycle_id), } ], } series["chicken_countings"] = { "count": 1, "page": 1, "page_size": 500, "results": [ { "date": "2026-09-02", "total_count": 9900, "mortality_count": 10, "cycle": int(cycle_id), } ], } series["chicken_weights"] = { "count": 1, "page": 1, "page_size": 500, "results": [ { "date": "2026-09-02", "age": 1, "doc_weight": 42, "average_weight": 50.0, "chicken_count": 100, "uniformity": 90.0, "average_daily_gain": 8.0, "cycle": int(cycle_id), } ], } series["manual_inputs"] = { "count": 1, "page": 1, "page_size": 500, "results": [ { "date": "2026-09-02", "age_manual": 1, "mortality_manual": 0, "cycle": int(cycle_id), } ], } series["ai_insights"] = { "count": 1, "page": 1, "page_size": 500, "results": [ { "date": "2026-09-02", "section": "health", "session": "am", "insight_text": "ok", "alert": "none", "cycle": int(cycle_id), } ], } series["kpis"] = { "count": 1, "page": 1, "page_size": 500, "results": [ { "date": "2026-09-02", "age": 1, "fcr": 1.2, "eef": 300.0, "chicken_life": 9900, "cycle": int(cycle_id), } ], } body = { "site_id": "sukawarna-0002", "cycle_id": int(cycle_id), "sites": [], "data": series, } return httpx.Response(200, json=body) # Structure-only call body = { "site_id": "sukawarna-0002", "sites": [ { "site_id": "sukawarna-0002", "site_name": "Sukawarna", "kandangs": [ { "kandang_id": 101, "kandang_name": "K1", "feed_in_button_urls": [], "cycles": [ { "id": 55, "kandang": 101, "status": "active", "start_date": self.start.isoformat(), "end_date": None, "chick_in_weight": 42, "doc_in_count": 10000, "total_days": 1, "feed_initial_balance": 120, "feed_initial_balance_date": self.start.isoformat(), "feed_initial_balance_iot": 115, "feed_initial_balance_accuracy": 95.8, "feed_initial_balance_sync_error": None, }, { "id": 50, "kandang": 101, "status": "closed", "start_date": "2026-01-01", "end_date": "2026-02-01", "chick_in_weight": 40, "doc_in_count": 9000, "total_days": 32, }, { "id": 49, "kandang": 101, "status": "closed", "start_date": "2025-10-01", "end_date": "2025-11-01", "chick_in_weight": 40, "doc_in_count": 9000, "total_days": 32, }, { "id": 48, "kandang": 101, "status": "closed", "start_date": "2025-07-01", "end_date": "2025-08-01", "chick_in_weight": 40, "doc_in_count": 9000, "total_days": 32, }, { "id": 47, "kandang": 101, "status": "closed", "start_date": "2025-04-01", "end_date": "2025-05-01", "chick_in_weight": 40, "doc_in_count": 9000, "total_days": 32, }, ], } ], } ], "data": _empty_series(), } return httpx.Response(200, json=body) return handler def test_mirror_upsert_idempotent_and_selects_closed_limit(self): transport = httpx.MockTransport(self._handler()) client = FarmExportClient(self.active, transport=transport) summary = sync_active_site_safe( self.active, client=client, closed_limit=3, page_size=500 ) self.assertEqual(summary.status, "ok", summary.message) self.assertEqual(Cycle.objects.filter(active_site=self.active).count(), 5) # active + last 3 closed = 4 selected (47 dropped) self.assertEqual(summary.selected_cycles, 4) self.assertFalse( Cycle.objects.filter(active_site=self.active, source_cycle_id=47).exists() and Cycle.objects.get( active_site=self.active, source_cycle_id=47 ).feed_sacks.exists() ) # Oldest closed is still created from structure but not series-synced self.assertTrue( Cycle.objects.filter(active_site=self.active, source_cycle_id=47).exists() ) active_cycle = Cycle.objects.get(active_site=self.active, source_cycle_id=55) self.assertEqual(active_cycle.feed_initial_balance, 120) self.assertEqual(active_cycle.feed_initial_balance_date, self.start) self.assertEqual(active_cycle.feed_initial_balance_iot, 115) self.assertEqual(active_cycle.feed_initial_balance_accuracy, 95.8) self.assertIsNone(active_cycle.feed_initial_balance_sync_error) self.assertEqual(FeedSacks.objects.filter(cycle=active_cycle).count(), 1) self.assertEqual(KPI.objects.filter(cycle=active_cycle).count(), 1) self.assertEqual(AIInsight.objects.filter(cycle=active_cycle).count(), 1) self.assertEqual(Flock.objects.filter(source_flock_id=201).count(), 1) self.assertEqual(IotPanel.objects.count(), 1) # Second sync is idempotent summary2 = sync_active_site_safe( self.active, client=FarmExportClient(self.active, transport=transport) ) self.assertEqual(summary2.status, "ok", summary2.message) self.assertEqual(FeedSacks.objects.filter(cycle=active_cycle).count(), 1) self.assertEqual(IotPanel.objects.count(), 1) self.active.refresh_from_db() self.assertEqual(self.active.last_sync_status, "ok") def test_sync_api_endpoint(self): transport = httpx.MockTransport(self._handler()) api = APIClient() api.force_authenticate(self.gm) with patch( "apps.sync.services.mirror_pull.FarmExportClient", side_effect=lambda active_site, **kwargs: FarmExportClient( active_site, transport=transport ), ): response = api.post(f"/api/v1/active-sites/{self.active.pk}/sync/") self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["summary"]["status"], "ok") self.active.refresh_from_db() self.assertEqual(self.active.last_sync_status, "ok") class CloseIngestSourceKandangTests(TestCase): def setUp(self): self.gm = User.objects.create_user( user_name="gm-ingest", password="x", status=User.STATUS_GM ) self.machine = User.objects.create_user( user_name="bot", password="x", status=User.STATUS_GM ) self.active = ActiveSite.objects.create( code="site-a", name="Site A", source_site_id="site-a-0001", api_base_url="https://a.example/api/v1", api_key="k", managed_by=self.gm, ) self.client = APIClient() self.client.force_authenticate(self.machine) def test_ingest_prefers_source_kandang_id(self): response = self.client.post( "/api/v1/cycle-close/ingest/", { "active_site_id": self.active.pk, "source_cycle_id": 99, "source_kandang_id": 777, "kandang_name": "Kandang Baru", "site_name": "Site A", "start_date": "2026-09-01", "proposed_end_date": "2026-09-15", "doc_in_weight": 40, "doc_in_count": 1000, }, format="json", ) self.assertEqual(response.status_code, 200, response.data) kandang = Kandang.objects.get(source_kandang_id=777) self.assertEqual(kandang.kandang_name, "Kandang Baru") cycle = Cycle.objects.get(active_site=self.active, source_cycle_id=99) self.assertEqual(cycle.kandang_id, kandang.pk) class AIInsightUniqueTests(TestCase): def setUp(self): owner = User.objects.create_user(user_name="owner", password="x") site = Site.objects.create(site_id="s-1", site_name="S", user=owner) kandang = Kandang.objects.create(site=site, kandang_name="K") self.cycle = Cycle.objects.create( kandang=kandang, start_date=date.today(), doc_in_weight=40, doc_in_count=100, ) def test_unique_constraint_allows_upsert_key(self): AIInsight.objects.create( cycle=self.cycle, date=date.today(), section="a", session="am", insight_text="one", alert="", ) AIInsight.objects.update_or_create( cycle=self.cycle, date=date.today(), section="a", session="am", defaults={"insight_text": "two", "alert": "x"}, ) self.assertEqual(AIInsight.objects.filter(cycle=self.cycle).count(), 1) self.assertEqual( AIInsight.objects.get(cycle=self.cycle).insight_text, "two" )