Files
dashboard-cpsp-executive/backend/apps/farms/tests.py
T
2026-09-08 16:17:29 +07:00

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