from datetime import date, timedelta from unittest.mock import patch 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 FeedSacks, ManualInput from apps.sync.models import CitySite def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site: """GM scope only sees sites linked to an active CitySite they registered.""" city = CitySite.objects.create( code=code, name=site_name, api_base_url=f"https://{code}.example/api/v1", registered_by=owner, ) return Site.objects.create(site_name=site_name, user=owner, city_site=city) class FarmAPITests(TestCase): def setUp(self): self.user = User.objects.create_user(user_login="tester", password="secret") self.admin = User.objects.create_user( user_login="admin", password="secret", status=User.STATUS_DIRECTOR_ADMIN, ) self.client = APIClient() self.site = _gm_visible_site( owner=self.user, site_name="Sukawarna", code="sukawarna" ) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) def test_create_flock_under_kandang_requires_director_admin(self): self.client.force_authenticate(self.admin) response = self.client.post( "/api/v1/flocks/", {"flock_name": "Lantai 1", "kandang": self.kandang.pk}, format="json", ) self.assertEqual(response.status_code, 201, response.data) self.assertEqual(response.data["flock_name"], "Lantai 1") self.assertEqual(response.data["kandang"], self.kandang.pk) def test_staff_cannot_create_flock(self): self.client.force_authenticate(self.user) response = self.client.post( "/api/v1/flocks/", {"flock_name": "Lantai 1", "kandang": self.kandang.pk}, format="json", ) self.assertEqual(response.status_code, 403, response.data) def test_create_cycle_without_flock(self): self.client.force_authenticate(self.user) start = date.today() response = self.client.post( "/api/v1/cycles/", { "kandang": self.kandang.pk, "total_days": 35, "start_date": start.isoformat(), "end_date": (start + timedelta(days=34)).isoformat(), "chick_in_weight": 42, "doc_in_count": 10000, "status": "active", }, format="json", ) self.assertEqual(response.status_code, 201, response.data) self.assertNotIn("flock", response.data) self.assertNotIn("flock_name", response.data) def test_update_cycle(self): self.client.force_authenticate(self.user) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) response = self.client.patch( f"/api/v1/cycles/{cycle.pk}/", { "doc_in_count": 9500, "chick_in_weight": 42, }, format="json", ) self.assertEqual(response.status_code, 200, response.data) cycle.refresh_from_db() # end_date is not writable via PATCH (close flow uses approve-close). self.assertEqual(cycle.end_date, start + timedelta(days=34)) self.assertEqual(cycle.doc_in_count, 9500) self.assertEqual(cycle.doc_in_weight, 42) def test_cycle_stays_active_without_gm_approval(self): """HQ does not auto-close by publish cutoff — GM approve-close is required.""" self.client.force_authenticate(self.user) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) response = self.client.get(f"/api/v1/cycles/{cycle.pk}/") self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE) def test_gm_can_approve_cycle_close(self): gm = User.objects.create_user(user_login="gm_approver", password="x", status=User.STATUS_GM) site = _gm_visible_site(owner=gm, site_name="GM Site", code="gm-approve") kandang = Kandang.objects.create(kandang_name="Kandang GM", site=site) self.client.force_authenticate(gm) start = date(2026, 8, 1) proposed = start + timedelta(days=34) cycle = Cycle.objects.create( kandang=kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=proposed, ) response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/") self.assertEqual(response.status_code, 200, response.data) body = response.data.get("cycle", response.data) self.assertEqual(body["status"], Cycle.STATUS_CLOSED) self.assertEqual(body["end_date"], proposed.isoformat()) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_CLOSED) self.assertEqual(cycle.close_approved_by_id, gm.pk) self.assertIsNotNone(cycle.close_approved_at) self.assertEqual(cycle.end_date, proposed) def test_gm_can_reject_cycle_close(self): gm = User.objects.create_user(user_login="gm_rejector", password="x", status=User.STATUS_GM) site = _gm_visible_site(owner=gm, site_name="GM Reject Site", code="gm-reject") kandang = Kandang.objects.create(kandang_name="Kandang R", site=site) self.client.force_authenticate(gm) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=start + timedelta(days=20), ) response = self.client.post(f"/api/v1/cycles/{cycle.pk}/reject-close/") self.assertEqual(response.status_code, 200, response.data) body = response.data.get("cycle", response.data) self.assertEqual(body["status"], Cycle.STATUS_ACTIVE) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE) self.assertIsNone(cycle.proposed_end_date) def test_gm_admin_cannot_approve_cycle_close(self): gm_admin = User.objects.create_user( user_login="gm_admin_user", password="x", status=User.STATUS_GM_ADMIN ) site = _gm_visible_site( owner=gm_admin, site_name="GM Admin Site", code="gm-admin-site" ) kandang = Kandang.objects.create(kandang_name="Kandang GMA", site=site) self.client.force_authenticate(gm_admin) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=start + timedelta(days=34), ) response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/") self.assertEqual(response.status_code, 403) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE) def test_flock_persists_across_cycles(self): flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) start = date.today() for offset in (0, 40): Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start + timedelta(days=offset), end_date=start + timedelta(days=offset + 34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1) self.assertEqual(flock.kandang_id, self.kandang.pk) def test_current_day_is_zero_based_from_start_date(self): start = date(2026, 9, 2) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) with patch( "apps.operations.services.visibility.visible_through_date", return_value=start, ): self.assertEqual(cycle.current_day, 0) with patch( "apps.operations.services.visibility.visible_through_date", return_value=start + timedelta(days=1), ): self.assertEqual(cycle.current_day, 1) def test_list_flocks_by_kandang(self): self.client.force_authenticate(self.user) Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang) other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site) Flock.objects.create(flock_name="Other", kandang=other_kandang) response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk}) self.assertEqual(response.status_code, 200, response.data) results = response.data["results"] if isinstance(response.data, dict) else response.data self.assertEqual(len(results), 2) def test_set_initial_balance_does_not_create_feed_sack_row(self): start = date.today() cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) self.client.force_authenticate(self.user) response = self.client.post( f"/api/v1/cycles/{cycle.pk}/initial-balance/", {"date": start.isoformat(), "feed_in_manual": 120}, format="json", ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat()) cycle.refresh_from_db() self.assertEqual(cycle.feed_initial_balance, 120) self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists()) self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists()) def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self): start = date.today() cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) feed_row = FeedSacks.objects.create( cycle=cycle, date=start + timedelta(days=1), in_today=4, in_total=4, out_today=0, out_total=0, feed_use_today=0, feed_use_total=0, ) self.client.force_authenticate(self.user) response = self.client.post( f"/api/v1/cycles/{cycle.pk}/initial-balance/", {"date": start.isoformat(), "feed_in_manual": 120}, format="json", ) self.assertEqual(response.status_code, 200, response.data) feed_row.refresh_from_db() self.assertEqual(feed_row.in_total, 4) def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self): start = date(2026, 8, 26) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) feed_row = FeedSacks.objects.create( cycle=cycle, date=start + timedelta(days=2), in_today=6, in_total=6, out_today=0, out_total=0, feed_use_today=0, feed_use_total=0, ) self.client.force_authenticate(self.user) first = self.client.post( f"/api/v1/cycles/{cycle.pk}/initial-balance/", {"date": start.isoformat(), "feed_in_manual": 120}, format="json", ) self.assertEqual(first.status_code, 200, first.data) second = self.client.post( f"/api/v1/cycles/{cycle.pk}/initial-balance/", {"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80}, format="json", ) self.assertEqual(second.status_code, 200, second.data) cycle.refresh_from_db() self.assertEqual(cycle.feed_initial_balance, 80) self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1)) self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1) feed_row.refresh_from_db() self.assertEqual(feed_row.in_total, 6) @patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None)) def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull): start = date(2026, 8, 26) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) self.client.force_authenticate(self.user) response = self.client.post( f"/api/v1/cycles/{cycle.pk}/initial-balance/", {"date": start.isoformat(), "feed_in_manual": 120}, format="json", ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["iot"], 115) self.assertEqual(response.data["accuracy"], 95.8) mock_pull.assert_called_once_with(cycle, start) @patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None)) def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull): start = date(2026, 8, 27) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, feed_initial_balance=120, status=Cycle.STATUS_ACTIVE, ) self.client.force_authenticate(self.user) response = self.client.get( f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/", {"date": start.isoformat(), "manual": 120}, ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["iot"], 112) self.assertEqual(response.data["accuracy"], 93.3) mock_pull.assert_called_once_with(cycle, start)