423 lines
16 KiB
Python
423 lines
16 KiB
Python
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 apps.accounts.models import ApiKey
|
|
|
|
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,
|
|
)
|
|
site_key = "site-close-callback-key"
|
|
api_key, _ = ApiKey.generate(self.user, "site")
|
|
api_key.prefix = site_key[:8]
|
|
api_key.key_hash = ApiKey.hash_key(site_key)
|
|
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
|
|
|
|
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=site_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)
|