Files
dashboard-cpsp-executive/backend/apps/sync/tests_mirror_pull.py
T

399 lines
16 KiB
Python

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"
)