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 class FarmAPITests(TestCase): def setUp(self): self.user = User.objects.create_user(user_name="tester", password="secret") self.admin = User.objects.create_user( user_name="admin", password="secret", status=User.STATUS_SUPERADMIN, ) self.client = APIClient() self.site = Site.objects.create(site_name="Sukawarna", user=self.user) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) def test_create_flock_under_kandang_requires_superadmin(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) original_end = start + timedelta(days=34) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=original_end, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) new_end = start + timedelta(days=39) response = self.client.patch( f"/api/v1/cycles/{cycle.pk}/", { "end_date": new_end.isoformat(), "total_days": 40, "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 GM-approval only — client writes are ignored; total_days # is recomputed from the existing end_date on full save. self.assertEqual(cycle.end_date, original_end) self.assertEqual(cycle.total_days, 35) self.assertEqual(cycle.doc_in_count, 9500) self.assertEqual(cycle.doc_in_weight, 42) @patch("apps.operations.services.visibility.visible_through_date", return_value=date(2026, 9, 10)) def test_cycle_stays_active_without_gm_approval(self, _mock_visible): 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_active_user_can_request_and_cancel_close(self): self.client.force_authenticate(self.user) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=self.kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) proposed = start + timedelta(days=34) response = self.client.post( f"/api/v1/cycles/{cycle.pk}/request-close/", {"proposed_end_date": proposed.isoformat()}, format="json", ) self.assertEqual(response.status_code, 200, response.data) body = response.data.get("cycle", response.data) self.assertEqual(body["status"], Cycle.STATUS_PENDING_CLOSE) self.assertEqual(body["proposed_end_date"], proposed.isoformat()) cancel = self.client.post(f"/api/v1/cycles/{cycle.pk}/cancel-close-request/", {}, format="json") self.assertEqual(cancel.status_code, 200, cancel.data) cancel_body = cancel.data.get("cycle", cancel.data) self.assertEqual(cancel_body["status"], Cycle.STATUS_ACTIVE) self.assertIsNone(cancel_body["proposed_end_date"]) def test_superadmin_cannot_request_close(self): self.client.force_authenticate(self.admin) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=self.kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) response = self.client.post( f"/api/v1/cycles/{cycle.pk}/request-close/", {"proposed_end_date": (start + timedelta(days=30)).isoformat()}, format="json", ) self.assertEqual(response.status_code, 400, response.data) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE) def test_session_user_cannot_apply_close_decision(self): self.client.force_authenticate(self.user) start = date(2026, 8, 1) end = start + timedelta(days=34) cycle = Cycle.objects.create( kandang=self.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=end, ) response = self.client.post( f"/api/v1/cycles/{cycle.pk}/apply-close-decision/", {"decision": "approve", "end_date": end.isoformat()}, format="json", ) self.assertEqual(response.status_code, 403, response.data) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE) def test_apply_close_decision_approve(self): from django.test import override_settings start = date(2026, 8, 1) end = start + timedelta(days=34) cycle = Cycle.objects.create( kandang=self.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=end, ) with override_settings(PUSAT_API_KEY="pusat-test-key"): response = self.client.post( f"/api/v1/cycles/{cycle.pk}/apply-close-decision/", {"decision": "approve", "end_date": end.isoformat()}, format="json", HTTP_X_API_KEY="pusat-test-key", ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED) self.assertEqual(response.data["end_date"], end.isoformat()) self.assertEqual(response.data["total_days"], 35) 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)