refactor
This commit is contained in:
1 parent
6f36db63a1
commit
250cdbab7f
42 files changed
+96363
-11699
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
کارخانههای ساخت داده آزمایشی برای تستهای پروژه.
|
||||
|
||||
این توابع فقط روی دیتابیس تستی Django اجرا میشوند و هیچگاه به
|
||||
`data9/kt.sqlite3` (دیتابیس تولیدی) دست نمیزنند.
|
||||
"""
|
||||
import itertools
|
||||
|
||||
from django.contrib.auth.models import Permission, User
|
||||
|
||||
from admin_panel.models import (Customer, PhoneNumber, Product, ProductCategory,
|
||||
ProductInStore, StoreRoom, StoreRoomOwner)
|
||||
|
||||
_counter = itertools.count(1)
|
||||
|
||||
|
||||
def _next(prefix):
|
||||
return f"{prefix}{next(_counter)}"
|
||||
|
||||
|
||||
def make_user(username=None, *, password='TestPass!2345', superuser=False,
|
||||
first_name='', last_name='', perms=()):
|
||||
"""
|
||||
ساخت کاربر. با `superuser=True` تمام بررسیهای دسترسی عبور داده میشوند.
|
||||
`perms` لیستی از کدنام پرمیشنهاست (مثلاً ['OnlyAnbar', 'ViewStore']).
|
||||
"""
|
||||
username = username or _next('user')
|
||||
if superuser:
|
||||
user = User.objects.create_superuser(username=username, email='', password=password)
|
||||
else:
|
||||
user = User.objects.create_user(username=username, password=password)
|
||||
|
||||
if first_name or last_name:
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
user.save(update_fields=['first_name', 'last_name'])
|
||||
|
||||
if perms:
|
||||
user.user_permissions.add(*Permission.objects.filter(codename__in=list(perms)))
|
||||
# رفرش کش پرمیشنها تا بلافاصله در دسترس باشند
|
||||
user = User.objects.get(pk=user.pk)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def make_store(name=None, *, viewers=(), owner=None):
|
||||
"""
|
||||
ساخت انبار.
|
||||
- `viewers` به M2M `user` اضافه میشوند (دسترسی مشاهده/تایید انتقال).
|
||||
- `owner` به M2M `owners` (از طریق StoreRoomOwner) اضافه میشود
|
||||
(همان چیزی که `StoreRoom.objects.get(owners=user)` انتظار دارد).
|
||||
"""
|
||||
store = StoreRoom.objects.create(store_name=name or _next('انبار'))
|
||||
|
||||
for v in viewers:
|
||||
store.user.add(v)
|
||||
|
||||
if owner is not None:
|
||||
StoreRoomOwner.objects.create(storeroom=store, user=owner)
|
||||
store.user.add(owner)
|
||||
|
||||
return store
|
||||
|
||||
|
||||
def make_category(name=None):
|
||||
return ProductCategory.objects.create(category_name=name or _next('دسته'))
|
||||
|
||||
|
||||
def make_product(name=None, *, price=1000, category=None, year=1400):
|
||||
return Product.objects.create(
|
||||
product_name=name or _next('کالا'),
|
||||
product_price=price,
|
||||
ProductCategory=category or make_category(),
|
||||
year=year,
|
||||
)
|
||||
|
||||
|
||||
def stock_in(store, product, quantity, *, year=1400):
|
||||
"""ثبت ورود موجودی اولیه به انبار (از مسیر امن ProductInStore.save)."""
|
||||
return ProductInStore.objects.create(
|
||||
StoreRoom=store,
|
||||
Product=product,
|
||||
s_in=quantity,
|
||||
s_out=0,
|
||||
year=year,
|
||||
desc=f"موجودی اولیه: {quantity}",
|
||||
movement_type=ProductInStore.MovementType.INITIAL,
|
||||
)
|
||||
|
||||
|
||||
def current_stock(store, product, *, year=1400):
|
||||
"""موجودی لحظهای کالا در انبار (s_in - s_out)."""
|
||||
total = 0
|
||||
for row in ProductInStore.objects.filter(StoreRoom=store, Product=product, year=year):
|
||||
total += (row.s_in or 0) - (row.s_out or 0)
|
||||
return total
|
||||
|
||||
|
||||
def make_customer(name=None, *, address='آدرس تست', phones=(), is_active=True):
|
||||
customer = Customer.objects.create(
|
||||
name_family=name or _next('مشتری'),
|
||||
address=address,
|
||||
is_active=is_active,
|
||||
)
|
||||
for i, phone in enumerate(phones):
|
||||
PhoneNumber.objects.create(customer=customer, title=f'تلفن {i + 1}', phone_number=phone)
|
||||
return customer
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
تستهای قرارداد مشتری: افزودن، ویرایش و نگهبان حذف (عدم حذف مشتری دارای سابقه).
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from admin_panel.models import Article, Customer, PhoneNumber
|
||||
|
||||
from .factories import make_customer, make_user
|
||||
|
||||
|
||||
class CustomerCrudTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('csUser', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_add_customer_creates_phones(self):
|
||||
url = reverse('admin_panel:customersAdd')
|
||||
response = self.client.post(url, {
|
||||
'an_name': 'علی رضایی',
|
||||
'an_address': 'تهران',
|
||||
'installDate': '1400/01/01',
|
||||
'idnCode': '1234567890',
|
||||
'phone_titles': ['موبایل', 'ثابت', ''],
|
||||
'phones': ['09120000000', '02112345678', ''],
|
||||
})
|
||||
|
||||
self.assertIn(response.status_code, (200, 302))
|
||||
customer = Customer.objects.get(name_family='علی رضایی')
|
||||
self.assertEqual(customer.phone1, '09120000000')
|
||||
self.assertEqual(customer.phone2, '02112345678')
|
||||
# ردیف خالی نباید ذخیره شود
|
||||
self.assertEqual(PhoneNumber.objects.filter(customer=customer).count(), 2)
|
||||
|
||||
def test_edit_customer_replaces_phones(self):
|
||||
customer = make_customer('مشتری ویرایش', phones=['09120000001'])
|
||||
url = reverse('admin_panel:customerEdit', kwargs={'c_id': customer.id})
|
||||
|
||||
response = self.client.post(url, {
|
||||
'an_name': 'مشتری ویرایش شده',
|
||||
'an_address': 'شیراز',
|
||||
'installDate': '1401/02/03',
|
||||
'idnCode': '999',
|
||||
'laghv_bazdid': 'true',
|
||||
'phone_titles': ['موبایل جدید'],
|
||||
'phones': ['09120009999'],
|
||||
})
|
||||
self.assertIn(response.status_code, (200, 302))
|
||||
|
||||
customer.refresh_from_db()
|
||||
self.assertEqual(customer.name_family, 'مشتری ویرایش شده')
|
||||
self.assertTrue(customer.laghv_bazdid)
|
||||
phones = list(PhoneNumber.objects.filter(customer=customer))
|
||||
self.assertEqual(len(phones), 1)
|
||||
self.assertEqual(phones[0].phone_number, '09120009999')
|
||||
|
||||
|
||||
class CustomerDeleteGuardTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('delUser', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_customer_without_history_is_deactivated(self):
|
||||
customer = make_customer('مشتری خام')
|
||||
url = reverse('admin_panel:customerDel', kwargs={'c_id': customer.id})
|
||||
|
||||
self.client.get(url)
|
||||
|
||||
customer.refresh_from_db()
|
||||
# حذف نرم: رکورد باقی میماند ولی غیرفعال میشود
|
||||
self.assertFalse(customer.is_active)
|
||||
|
||||
def test_customer_with_financial_history_is_never_deleted(self):
|
||||
customer = make_customer('مشتری دارای سابقه')
|
||||
Article.objects.create(customer=customer, debit=1000, credit=0, desc='سابقه مالی')
|
||||
|
||||
url = reverse('admin_panel:customerDel', kwargs={'c_id': customer.id})
|
||||
self.client.get(url)
|
||||
|
||||
customer.refresh_from_db()
|
||||
self.assertTrue(customer.is_active, msg="مشتری دارای سابقه مالی نباید حذف/غیرفعال شود.")
|
||||
self.assertTrue(Customer.objects.filter(id=customer.id).exists())
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
تستهای قرارداد فاکتور: حذف نرم، محافظت از اسناد انبار و جریان ساخت فاکتور از ویو.
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.db.models import ProtectedError
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from admin_panel.models import (Article, Factor, FactorIteam, ProductInStore)
|
||||
|
||||
from .factories import current_stock, make_customer, make_product, make_store, make_user, stock_in
|
||||
|
||||
|
||||
class FactorSoftDeleteTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.customer = make_customer('مشتری فاکتور')
|
||||
self.factor = Factor.objects.create(
|
||||
customer=self.customer, amount=250_000, pay_status='unpaid',
|
||||
FactorType='Nasb', PayType='naghd',
|
||||
)
|
||||
|
||||
def test_delete_is_soft(self):
|
||||
factor_id = self.factor.id
|
||||
self.factor.delete()
|
||||
|
||||
# رکورد باید باقی بماند اما باطل شده باشد
|
||||
still_there = Factor.objects.get(id=factor_id)
|
||||
self.assertFalse(still_there.is_active)
|
||||
self.assertEqual(still_there.pay_status, 'cancell')
|
||||
|
||||
def test_instore_transaction_protects_its_factor(self):
|
||||
store = make_store('انبار فاکتور')
|
||||
product = make_product('کالای فاکتور')
|
||||
ProductInStore.objects.create(
|
||||
StoreRoom=store, Product=product, factor=self.factor,
|
||||
s_in=0, s_out=1, desc='خروج فاکتور',
|
||||
movement_type=ProductInStore.MovementType.SALE,
|
||||
)
|
||||
|
||||
with self.assertRaises(ProtectedError):
|
||||
Factor.objects.filter(id=self.factor.id).delete()
|
||||
|
||||
|
||||
class NewFactor2ViewTests(TestCase):
|
||||
"""
|
||||
تست جریان اصلی ویو `newFactor2` (مسیر استاندارد ساخت فاکتور) که پس از
|
||||
تقسیم views.py به ماژول factors منتقل شده است.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('factorUser', superuser=True)
|
||||
self.store = make_store('انبار فاکتور', owner=self.user)
|
||||
self.customer = make_customer('مشتری فاکتور')
|
||||
self.product = make_product('کالای فروش', price=1000)
|
||||
stock_in(self.store, self.product, 20)
|
||||
|
||||
def test_new_factor2_creates_factor_article_and_stock_out(self):
|
||||
self.client.force_login(self.user)
|
||||
url = reverse('admin_panel:newFactor2', kwargs={'c_id': self.customer.id})
|
||||
|
||||
payload = {
|
||||
'prdsList': json.dumps([
|
||||
{'prd_id': self.product.id, 'prd_count': 3, 'price': 1000,
|
||||
'garanty_Esh': '', 'product_name': self.product.product_name},
|
||||
]),
|
||||
'PardakhtHa': json.dumps({'l_naghd': 3000}),
|
||||
'FactorType': 'Nasb',
|
||||
'FactorDesc': 'تست ساخت فاکتور',
|
||||
'sarResidDate': '',
|
||||
}
|
||||
response = self.client.post(url, payload)
|
||||
|
||||
self.assertIn(response.status_code, (200, 302), msg=f"status={response.status_code}")
|
||||
|
||||
factor = Factor.objects.filter(customer=self.customer).order_by('-id').first()
|
||||
self.assertIsNotNone(factor, msg="فاکتور ساخته نشد.")
|
||||
self.assertEqual(factor.amount, 3000)
|
||||
self.assertEqual(FactorIteam.objects.filter(factor=factor).count(), 1)
|
||||
|
||||
# از انبار به اندازه ۳ عدد کسر شده باشد
|
||||
self.assertEqual(current_stock(self.store, self.product), 17)
|
||||
|
||||
# سند بدهی بابت فاکتور ثبت شده باشد
|
||||
self.assertTrue(Article.objects.filter(factor=factor, debit=3000).exists())
|
||||
|
||||
def test_new_factor2_rejects_oversell_without_creating_factor(self):
|
||||
self.client.force_login(self.user)
|
||||
url = reverse('admin_panel:newFactor2', kwargs={'c_id': self.customer.id})
|
||||
|
||||
payload = {
|
||||
'prdsList': json.dumps([
|
||||
{'prd_id': self.product.id, 'prd_count': 999, 'price': 1000,
|
||||
'garanty_Esh': '', 'product_name': self.product.product_name},
|
||||
]),
|
||||
'PardakhtHa': json.dumps({}),
|
||||
'FactorType': 'Nasb',
|
||||
'FactorDesc': '',
|
||||
'sarResidDate': '',
|
||||
}
|
||||
response = self.client.post(url, payload)
|
||||
|
||||
# ویو نباید خطای سرور بدهد و نباید فاکتور ناقص ثبت شود
|
||||
self.assertNotEqual(response.status_code, 500)
|
||||
self.assertEqual(Factor.objects.filter(customer=self.customer).count(), 0)
|
||||
self.assertEqual(current_stock(self.store, self.product), 20)
|
||||
|
||||
def test_new_factor2_rejects_get(self):
|
||||
self.client.force_login(self.user)
|
||||
url = reverse('admin_panel:newFactor2', kwargs={'c_id': self.customer.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
تستهای اطمینان انبار: ریاضی موجودی، تاریخچه گردش کالا و چرخهٔ کامل انتقال بین انبارها.
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from admin_panel.models import (ProductInStore, ProductStockHistory,
|
||||
StoreTransferRequest)
|
||||
from admin_panel.services import (approve_transfer_request,
|
||||
cancel_transfer_request,
|
||||
create_transfer_request,
|
||||
get_product_current_stock,
|
||||
reject_transfer_request)
|
||||
|
||||
from .factories import (current_stock, make_product, make_store, make_user,
|
||||
stock_in)
|
||||
|
||||
|
||||
class ProductInStoreMathTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.store = make_store('انبار مرکزی')
|
||||
self.product = make_product('کالای تست', price=500)
|
||||
|
||||
def test_initial_in_records_history_and_stock(self):
|
||||
row = stock_in(self.store, self.product, 10)
|
||||
|
||||
row.refresh_from_db()
|
||||
self.assertEqual(row.current_stock_at_time, 10)
|
||||
self.assertEqual(current_stock(self.store, self.product), 10)
|
||||
|
||||
history = ProductStockHistory.objects.filter(store_room=self.store, product=self.product)
|
||||
self.assertEqual(history.count(), 1)
|
||||
self.assertEqual(history.first().quantity_changed, 10)
|
||||
self.assertEqual(history.first().current_stock, 10)
|
||||
|
||||
def test_multiple_movements_accumulate_correctly(self):
|
||||
stock_in(self.store, self.product, 10)
|
||||
ProductInStore.objects.create(
|
||||
StoreRoom=self.store, Product=self.product, s_in=0, s_out=4,
|
||||
desc='خروج تست', movement_type=ProductInStore.MovementType.DIRECT_OUT,
|
||||
)
|
||||
ProductInStore.objects.create(
|
||||
StoreRoom=self.store, Product=self.product, s_in=7, s_out=0,
|
||||
desc='ورود تست', movement_type=ProductInStore.MovementType.DIRECT_IN,
|
||||
)
|
||||
|
||||
self.assertEqual(current_stock(self.store, self.product), 13)
|
||||
self.assertEqual(get_product_current_stock(self.store, self.product), 13)
|
||||
self.assertEqual(
|
||||
ProductStockHistory.objects.filter(store_room=self.store, product=self.product).count(),
|
||||
3,
|
||||
)
|
||||
|
||||
def test_editing_a_row_updates_stock_and_history(self):
|
||||
row = stock_in(self.store, self.product, 10)
|
||||
|
||||
row.s_in = 25
|
||||
row.save()
|
||||
|
||||
row.refresh_from_db()
|
||||
self.assertEqual(row.current_stock_at_time, 25)
|
||||
self.assertEqual(current_stock(self.store, self.product), 25)
|
||||
|
||||
# آخرین رکورد تاریخچه باید تغییر (+15) را نشان دهد
|
||||
last = ProductStockHistory.objects.filter(
|
||||
store_room=self.store, product=self.product
|
||||
).order_by('-id').first()
|
||||
self.assertEqual(last.quantity_changed, 15)
|
||||
self.assertEqual(last.current_stock, 25)
|
||||
|
||||
def test_deleting_a_row_creates_history_and_reduces_stock(self):
|
||||
stock_in(self.store, self.product, 10)
|
||||
out_row = ProductInStore.objects.create(
|
||||
StoreRoom=self.store, Product=self.product, s_in=0, s_out=3,
|
||||
desc='خروج', movement_type=ProductInStore.MovementType.DIRECT_OUT,
|
||||
)
|
||||
self.assertEqual(current_stock(self.store, self.product), 7)
|
||||
|
||||
out_row.delete()
|
||||
|
||||
self.assertEqual(current_stock(self.store, self.product), 10)
|
||||
last = ProductStockHistory.objects.filter(
|
||||
store_room=self.store, product=self.product
|
||||
).order_by('-id').first()
|
||||
self.assertEqual(last.quantity_changed, 3)
|
||||
self.assertEqual(last.current_stock, 10)
|
||||
|
||||
def test_garbage_numeric_values_are_coerced_to_zero(self):
|
||||
row = ProductInStore.objects.create(
|
||||
StoreRoom=self.store, Product=self.product,
|
||||
s_in='abc', s_out=None, desc='مقدار خراب',
|
||||
)
|
||||
row.refresh_from_db()
|
||||
self.assertEqual(row.s_in, 0)
|
||||
self.assertEqual(row.s_out, 0)
|
||||
|
||||
|
||||
class TransferWorkflowTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.boss = make_user('boss', superuser=True)
|
||||
self.store_a = make_store('مبدا', viewers=[self.boss])
|
||||
self.store_b = make_store('مقصد', viewers=[self.boss])
|
||||
self.product = make_product('کالای انتقالی', price=1000)
|
||||
stock_in(self.store_a, self.product, 10)
|
||||
|
||||
def test_create_transfer_request_pending(self):
|
||||
transfer = create_transfer_request(
|
||||
source_store=self.store_a,
|
||||
target_store=self.store_b,
|
||||
product=self.product,
|
||||
quantity=4,
|
||||
requester=self.boss,
|
||||
)
|
||||
self.assertEqual(transfer.status, StoreTransferRequest.Status.PENDING)
|
||||
# تا زمان تایید، موجودی هیچ انباری تغییر نمیکند
|
||||
self.assertEqual(current_stock(self.store_a, self.product), 10)
|
||||
self.assertEqual(current_stock(self.store_b, self.product), 0)
|
||||
|
||||
def test_create_rejects_insufficient_stock(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=999, requester=self.boss,
|
||||
)
|
||||
|
||||
def test_create_rejects_same_store(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_a,
|
||||
product=self.product, quantity=1, requester=self.boss,
|
||||
)
|
||||
|
||||
def test_create_rejects_non_positive_quantity(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=0, requester=self.boss,
|
||||
)
|
||||
|
||||
def test_approve_moves_stock_and_links_transactions(self):
|
||||
transfer = create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=4, requester=self.boss,
|
||||
)
|
||||
|
||||
approved = approve_transfer_request(request_id=transfer.id, approver=self.boss)
|
||||
|
||||
self.assertEqual(approved.status, StoreTransferRequest.Status.APPROVED)
|
||||
self.assertIsNotNone(approved.out_transaction_id)
|
||||
self.assertIsNotNone(approved.in_transaction_id)
|
||||
self.assertEqual(current_stock(self.store_a, self.product), 6)
|
||||
self.assertEqual(current_stock(self.store_b, self.product), 4)
|
||||
|
||||
def test_double_approval_is_rejected(self):
|
||||
transfer = create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=4, requester=self.boss,
|
||||
)
|
||||
approve_transfer_request(request_id=transfer.id, approver=self.boss)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
approve_transfer_request(request_id=transfer.id, approver=self.boss)
|
||||
|
||||
def test_reject_keeps_stock_untouched(self):
|
||||
transfer = create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=4, requester=self.boss,
|
||||
)
|
||||
rejected = reject_transfer_request(request_id=transfer.id, rejector=self.boss, reason='کالا خراب')
|
||||
|
||||
self.assertEqual(rejected.status, StoreTransferRequest.Status.REJECTED)
|
||||
self.assertEqual(rejected.rejection_reason, 'کالا خراب')
|
||||
self.assertEqual(current_stock(self.store_a, self.product), 10)
|
||||
self.assertEqual(current_stock(self.store_b, self.product), 0)
|
||||
|
||||
def test_cancel_by_requester(self):
|
||||
transfer = create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=4, requester=self.boss,
|
||||
)
|
||||
canceled = cancel_transfer_request(request_id=transfer.id, user=self.boss)
|
||||
self.assertEqual(canceled.status, StoreTransferRequest.Status.CANCELED)
|
||||
|
||||
def test_permission_denied_for_outsider(self):
|
||||
outsider = make_user('outsider')
|
||||
with self.assertRaises(PermissionDenied):
|
||||
create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=1, requester=outsider,
|
||||
)
|
||||
|
||||
def test_approval_permission_denied_for_non_target_member(self):
|
||||
transfer = create_transfer_request(
|
||||
source_store=self.store_a, target_store=self.store_b,
|
||||
product=self.product, quantity=4, requester=self.boss,
|
||||
)
|
||||
outsider = make_user('outsider2')
|
||||
with self.assertRaises(PermissionDenied):
|
||||
approve_transfer_request(request_id=transfer.id, approver=outsider)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
قرارداد (Contract) پکیج مدلها پس از بازنویسی.
|
||||
|
||||
هدف: تضمین اینکه تقسیم `admin_panel/models.py` به پکیج `admin_panel/models/`
|
||||
هیچ نامی را حذف نکرده، `app_label`ها ثابت ماندهاند و نام جدولهای دیتابیس
|
||||
دقیقاً همان مقادیر پیشین هستند (شرط لازم برای «No changes detected» در مایگریشنها).
|
||||
"""
|
||||
from django.apps import apps
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
import admin_panel.models as models_pkg
|
||||
|
||||
# ۱۸ مدل اصلی فایل قدیمی models.py (پیش از بازنویسی)
|
||||
EXPECTED_MODELS = [
|
||||
'UserProfile',
|
||||
'Customer',
|
||||
'PhoneNumber',
|
||||
'StoreRoom',
|
||||
'StoreRoomOwner',
|
||||
'ProductCategory',
|
||||
'Product',
|
||||
'Factor',
|
||||
'ProductInStore',
|
||||
'ProductStockHistory',
|
||||
'StoreTransferRequest',
|
||||
'FactorIteam',
|
||||
'VisitQueue',
|
||||
'ArticleType',
|
||||
'Article',
|
||||
'Action',
|
||||
'Sarresid',
|
||||
'CacheTable',
|
||||
]
|
||||
|
||||
# نامهایی که کدهای دیگر از طریق star-import به آنها تکیه دارند
|
||||
# (مثلاً context_processor.py از `User` استفاده میکند که از همین نشتی میآید).
|
||||
LEAKED_NAMES = ['models', 'User', 'Sum', 'transaction', 'connection', 'ValidationError']
|
||||
|
||||
|
||||
class ModelsPackageContractTests(SimpleTestCase):
|
||||
|
||||
def test_all_models_importable_from_package(self):
|
||||
for name in EXPECTED_MODELS:
|
||||
self.assertTrue(
|
||||
hasattr(models_pkg, name),
|
||||
msg=f"مدل «{name}» از admin_panel.models قابل import نیست.",
|
||||
)
|
||||
|
||||
def test_models_are_registered_under_admin_panel_app(self):
|
||||
for name in EXPECTED_MODELS:
|
||||
model = getattr(models_pkg, name)
|
||||
self.assertEqual(
|
||||
model._meta.app_label, 'admin_panel',
|
||||
msg=f"app_label مدل {name} تغییر کرده است: {model._meta.app_label}",
|
||||
)
|
||||
|
||||
def test_db_table_names_are_unchanged_defaults(self):
|
||||
for name in EXPECTED_MODELS:
|
||||
model = getattr(models_pkg, name)
|
||||
expected = f"admin_panel_{name.lower()}"
|
||||
self.assertEqual(
|
||||
model._meta.db_table, expected,
|
||||
msg=f"نام جدول مدل {name} تغییر کرده است: {model._meta.db_table} != {expected}",
|
||||
)
|
||||
|
||||
def test_star_import_leak_names_still_present(self):
|
||||
"""
|
||||
رفتار star-import باید دقیقاً مانند فایل تکپارچهی قبلی حفظ شود؛ چراکه
|
||||
context_processor.py و چند ماژول دیگر به نشتی نامهایی مثل `User` و `models`
|
||||
وابستهاند.
|
||||
"""
|
||||
for name in LEAKED_NAMES:
|
||||
self.assertTrue(
|
||||
hasattr(models_pkg, name),
|
||||
msg=f"نام «{name}» که از طریق star-import منتشر میشد، حذف شده است.",
|
||||
)
|
||||
|
||||
def test_models_package_has_no_duplicate_registration(self):
|
||||
registered = [m for m in apps.get_app_config('admin_panel').get_models()]
|
||||
labels = [m.__name__ for m in registered]
|
||||
self.assertEqual(len(labels), len(set(labels)), msg=f"نام تکراری مدل: {labels}")
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
قرارداد URLها و ویوها پس از تقسیم `views.py` به پکیج `views/`.
|
||||
|
||||
نگهبان حذف تصادفی مسیر/ویو: مجموعهی نامهای URL با یک snapshot ثابت مقایسه میشود
|
||||
و هر نام باید با ``reverse()`` قابل بازیابی باشد.
|
||||
"""
|
||||
from django.test import SimpleTestCase
|
||||
from django.urls import reverse
|
||||
|
||||
# snapshot قطعی نامهای مسیرهای اپ admin_panel (۶۲ مسیر)
|
||||
EXPECTED_URL_NAMES = {
|
||||
'index',
|
||||
'p_login',
|
||||
'p_logout',
|
||||
'bedehi',
|
||||
'report',
|
||||
'inventory_movements_report',
|
||||
'financial_dashboard',
|
||||
'report_debtors_no_vosol',
|
||||
'rezayat_dashboard',
|
||||
'hesabdari',
|
||||
'hesabdari_residnashone',
|
||||
'hesabdari_check_pass',
|
||||
'hesabdari_check_pass_fail',
|
||||
'bedehiKhadamat',
|
||||
'bedehiNasb',
|
||||
'customers',
|
||||
'customersSearch',
|
||||
'customersAdd',
|
||||
'customer',
|
||||
'api_check_status_inquiry',
|
||||
'api_check_sabt_inquiry',
|
||||
'customer2',
|
||||
'customerNew',
|
||||
'customerAction',
|
||||
'customerNazar',
|
||||
'customerNewAction',
|
||||
'customerEditAction',
|
||||
'customerDel',
|
||||
'customerFactors',
|
||||
'customerFactor',
|
||||
'FactorDel',
|
||||
'newFactor',
|
||||
'newFactor2',
|
||||
'customerHesabdari',
|
||||
'customerSarresidh',
|
||||
'customerHesabdariAdd',
|
||||
'stores',
|
||||
'productsJson',
|
||||
'ActionJson',
|
||||
'CustomerJson',
|
||||
'productMojodi',
|
||||
'store',
|
||||
'operations',
|
||||
'operationProduct',
|
||||
'newOperation',
|
||||
'newStore',
|
||||
'store_transfers',
|
||||
'process_transfer_action',
|
||||
'productAdd',
|
||||
'productEdit',
|
||||
'operation',
|
||||
'productDel',
|
||||
'customerEdit',
|
||||
'rezayat',
|
||||
'rezayatEdit',
|
||||
'entezar',
|
||||
'ActionList',
|
||||
'ActionListToday',
|
||||
'ActionListUserToday',
|
||||
'ActionListAll',
|
||||
'passCheck',
|
||||
'update_check_info',
|
||||
'addSarresid',
|
||||
'pardakhtNesiye',
|
||||
}
|
||||
|
||||
# مسیرهایی که آرگومان میگیرند: نام -> kwargs نمونه
|
||||
PARAMETERIZED = {
|
||||
'customersSearch': {'searchVal': 'ali', 'searchOn': 'name'},
|
||||
'customer': {'c_id': 1},
|
||||
'customer2': {'c_id': 1},
|
||||
'customerNew': {'c_id': 1},
|
||||
'customerAction': {'c_id': 1},
|
||||
'customerNazar': {'c_id': 1},
|
||||
'customerNewAction': {'c_id': 1},
|
||||
'customerEditAction': {'c_id': 1, 'action_id': 1},
|
||||
'customerDel': {'c_id': 1},
|
||||
'customerFactors': {'c_id': 1},
|
||||
'customerFactor': {'c_id': 1, 'f_id': 1},
|
||||
'FactorDel': {'c_id': 1, 'f_id': 1},
|
||||
'newFactor': {'c_id': 1},
|
||||
'newFactor2': {'c_id': 1},
|
||||
'customerHesabdari': {'c_id': 1},
|
||||
'customerSarresidh': {'c_id': 1},
|
||||
'customerHesabdariAdd': {'c_id': 1},
|
||||
'ActionJson': {'action_id': 1},
|
||||
'CustomerJson': {'c_id': 1},
|
||||
'productMojodi': {'p_id': 1},
|
||||
'store': {'s_id': 1},
|
||||
'operations': {'s_id': 1},
|
||||
'operationProduct': {'s_id': 1, 'p_id': 1},
|
||||
'newOperation': {'s_id': 1, 'p_id': 1},
|
||||
'store_transfers': {'s_id': 1},
|
||||
'process_transfer_action': {'transfer_id': 1},
|
||||
'productDel': {'p_id': 1, 's_id': 1},
|
||||
'customerEdit': {'c_id': 1},
|
||||
'rezayatEdit': {'ac_id': 1},
|
||||
'ActionListUserToday': {'u_id': 1},
|
||||
'passCheck': {'articl_id': 1},
|
||||
'update_check_info': {'sarresid_id': 1},
|
||||
'addSarresid': {'cs_id': 1},
|
||||
'pardakhtNesiye': {'cs_id': 1},
|
||||
}
|
||||
|
||||
|
||||
class UrlContractTests(SimpleTestCase):
|
||||
|
||||
def _all_names(self):
|
||||
from admin_panel.urls import urlpatterns
|
||||
return {p.name for p in urlpatterns if p.name}
|
||||
|
||||
def test_url_name_snapshot_unchanged(self):
|
||||
names = self._all_names()
|
||||
missing = EXPECTED_URL_NAMES - names
|
||||
added = names - EXPECTED_URL_NAMES
|
||||
self.assertEqual(
|
||||
missing, set(),
|
||||
msg=f"این نامهای URL پس از refactor گم شدهاند: {sorted(missing)}",
|
||||
)
|
||||
self.assertEqual(
|
||||
added, set(),
|
||||
msg=f"نامهای URL جدید و پیشبینینشده اضافه شدهاند: {sorted(added)}",
|
||||
)
|
||||
|
||||
def test_every_url_reverses(self):
|
||||
for name in sorted(EXPECTED_URL_NAMES):
|
||||
kwargs = PARAMETERIZED.get(name, {})
|
||||
with self.subTest(url=name):
|
||||
try:
|
||||
reverse(f'admin_panel:{name}', kwargs=kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - هدف: گزارش دقیق نام مشکلدار
|
||||
self.fail(f"reverse('admin_panel:{name}') شکست خورد: {exc}")
|
||||
|
||||
def test_all_view_callables_are_importable(self):
|
||||
"""هر `views.X` در urls.py باید در پکیج views موجود و قابل فراخوانی باشد."""
|
||||
from admin_panel import views
|
||||
from admin_panel.urls import urlpatterns
|
||||
|
||||
for pattern in urlpatterns:
|
||||
if pattern.name not in EXPECTED_URL_NAMES:
|
||||
# مسیرهای سرو استاتیک (از static()) ویوی اپ نیستند
|
||||
continue
|
||||
callback = getattr(pattern, 'callback', None)
|
||||
if callback is None:
|
||||
continue
|
||||
self.assertTrue(
|
||||
callable(callback),
|
||||
msg=f"ویو مسیر «{pattern.name}» قابل فراخوانی نیست.",
|
||||
)
|
||||
self.assertTrue(
|
||||
callback.__module__.startswith('admin_panel.views'),
|
||||
msg=(
|
||||
f"ویو مسیر «{pattern.name}» بجای پکیج admin_panel.views از "
|
||||
f"ماژول {callback.__module__} میآید."
|
||||
),
|
||||
)
|
||||
# اطمینان از اینکه از دسترسپذیری نامها استفاده میکنیم
|
||||
self.assertTrue(hasattr(views, 'p_login'))
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
تست دودی (Smoke Test) تمام اندپوینتهای اپ admin_panel.
|
||||
|
||||
چرا این تست حیاتی است؟
|
||||
`python manage.py check` خطاهای `NameError`/`ImportError` را که داخل *بدنهٔ توابع*
|
||||
ویو رخ میدهند نمیبیند. پس از تقسیم `views.py` به پکیج `views/`، تنها راه مطمئن
|
||||
برای کشف importهای جاافتاده، فراخوانی واقعی هر ویو است. این ماژول دقیقاً همان کار
|
||||
را انجام میدهد: هر مسیر urls.py با یک کاربر مجاز (superuser) فراخوانی میشود و
|
||||
هیچ پاسخی نباید کد ۵۰۰ داشته باشد.
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from admin_panel.models import (Action, Article, Factor, Sarresid,
|
||||
StoreTransferRequest)
|
||||
|
||||
from .factories import (make_category, make_customer, make_product, make_store,
|
||||
make_user, stock_in)
|
||||
|
||||
# کدهای وضعیتی که «نتیجهٔ قابلقبول» تلقی میشوند (نشانهٔ اجرای سالم ویو)
|
||||
ACCEPTABLE = {200, 201, 302, 400, 403, 404, 405}
|
||||
|
||||
GET = 'get'
|
||||
POST = 'post'
|
||||
|
||||
|
||||
class ViewSmokeTests(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = make_user('smokeAdmin', superuser=True)
|
||||
cls.other_user = make_user('smokeOther')
|
||||
# انبار اصلی که کاربر مالک آن است (owners) و دسترسی مشاهده دارد
|
||||
cls.store = make_store('انبار اصلی', owner=cls.user)
|
||||
# نکته: کاربر عمداً به انبار دوم اضافه نمیشود؛ چون چند ویو از
|
||||
# `StoreRoom.objects.get(user__id=...)` استفاده میکنند که با دو انبار
|
||||
# خطای MultipleObjectsReturned میدهد (رفتار پیشین کد، نه ناشی از refactor).
|
||||
cls.store2 = make_store('انبار دوم')
|
||||
|
||||
cls.category = make_category('دسته اصلی')
|
||||
cls.product = make_product('کالای اصلی', price=2000, category=cls.category)
|
||||
cls.spare_product = make_product('کالای حذفی', price=1500, category=cls.category)
|
||||
stock_in(cls.store, cls.product, 100)
|
||||
stock_in(cls.store, cls.spare_product, 5)
|
||||
|
||||
cls.customer = make_customer('مشتری اصلی')
|
||||
cls.spare_customer = make_customer('مشتری خام')
|
||||
# یک سند مالی تا ویو «بدهی» مسیر موفق را طی کند (با دیتابیس کاملاً خالی،
|
||||
# این ویو به دلیل باگ ازپیشموجود UnboundLocalError خطای ۵۰۰ میدهد).
|
||||
Article.objects.create(customer=cls.customer, debit=1000, credit=0, desc='سند آغازین دودی')
|
||||
cls.factor = Factor.objects.create(customer=cls.customer, amount=1000, pay_status='unpaid')
|
||||
cls.spare_factor = Factor.objects.create(customer=cls.customer, amount=1000, pay_status='unpaid')
|
||||
|
||||
cls.action = Action.objects.create(
|
||||
customer=cls.customer, forUser=cls.user, actionType='bazdid', status='waite',
|
||||
actionDateTime=timezone.now(),
|
||||
)
|
||||
cls.sarresid = Sarresid.objects.create(
|
||||
customer=cls.customer, price=5000, payStatus='false', type='check',
|
||||
sarresidDT=timezone.now(),
|
||||
)
|
||||
cls.transfer = StoreTransferRequest.objects.create(
|
||||
source_store=cls.store, target_store=cls.store2,
|
||||
product=cls.product, quantity=1, requester=cls.user,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.user)
|
||||
# در صورت خطای سرور، استثنا را نگه میداریم تا کد وضعیت ۵۰۰ را ببینیم
|
||||
self.client.raise_request_exception = False
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _now_ms(self):
|
||||
return int(timezone.now().timestamp() * 1000)
|
||||
|
||||
def _url(self, name, **kwargs):
|
||||
return reverse(f'admin_panel:{name}', kwargs=kwargs)
|
||||
|
||||
def _endpoints(self):
|
||||
"""(نام مسیر, kwargs, متد, دادهٔ ارسالی)"""
|
||||
ms = self._now_ms()
|
||||
c = self.customer
|
||||
return [
|
||||
# --- داشبوردها و گزارشها ---
|
||||
('index', {}, GET, None),
|
||||
('p_login', {}, GET, None),
|
||||
('bedehi', {}, GET, None),
|
||||
('report', {}, GET, None),
|
||||
('inventory_movements_report', {}, GET, None),
|
||||
('financial_dashboard', {}, GET, None),
|
||||
('report_debtors_no_vosol', {}, GET, None),
|
||||
('rezayat_dashboard', {}, GET, None),
|
||||
('hesabdari', {}, GET, None),
|
||||
('hesabdari_residnashone', {}, GET, None),
|
||||
('hesabdari_check_pass', {}, GET, None),
|
||||
('hesabdari_check_pass_fail', {}, GET, None),
|
||||
('bedehiKhadamat', {}, GET, None),
|
||||
('bedehiNasb', {}, GET, None),
|
||||
# --- مشتریان ---
|
||||
('customers', {}, GET, None),
|
||||
# مسیر از converter نوع <slug> استفاده میکند، پس فقط کاراکترهای مجاز slug
|
||||
('customersSearch', {'searchVal': 'ali', 'searchOn': 'name'}, GET, None),
|
||||
('customersAdd', {}, POST, {
|
||||
'an_name': 'مشتری دودی', 'an_address': 'تهران',
|
||||
'installDate': '1400/01/01', 'idnCode': '111',
|
||||
'phone_titles': ['موبایل'], 'phones': ['09120000000'],
|
||||
}),
|
||||
('customer', {'c_id': c.id}, GET, None),
|
||||
('customer2', {'c_id': c.id}, GET, None),
|
||||
('customerNew', {'c_id': c.id}, GET, None),
|
||||
('customerAction', {'c_id': c.id}, GET, None),
|
||||
('customerNazar', {'c_id': c.id}, GET, None),
|
||||
('customerNewAction', {'c_id': c.id}, POST, {
|
||||
'actionType': 'bazdid', 'dateTimeAnjam': ms,
|
||||
'userID': self.user.id, 'desc': 'اقدام دودی',
|
||||
}),
|
||||
('customerEditAction', {'c_id': c.id, 'action_id': self.action.id}, POST, {
|
||||
'actionType': 'bazdid', 'dateTimeAnjam': ms,
|
||||
'userID': self.user.id, 'desc': 'ویرایش دودی',
|
||||
}),
|
||||
('customerDel', {'c_id': self.spare_customer.id}, GET, None),
|
||||
('customerFactors', {'c_id': c.id}, GET, None),
|
||||
('customerFactor', {'c_id': c.id, 'f_id': self.factor.id}, GET, None),
|
||||
('FactorDel', {'c_id': c.id, 'f_id': self.spare_factor.id}, POST, {
|
||||
'cancel_reason': 'error', 'payment_action': 'keep',
|
||||
}),
|
||||
('newFactor', {'c_id': c.id}, POST, {
|
||||
'prdsList': json.dumps([]), 'payType': 'naghd',
|
||||
'FactorType': 'Nasb', 'FactorDesc': 'دودی', 'sarResidDate': '',
|
||||
}),
|
||||
('newFactor2', {'c_id': c.id}, POST, {
|
||||
'prdsList': json.dumps([]), 'PardakhtHa': json.dumps({}),
|
||||
'FactorType': 'Nasb', 'FactorDesc': 'دودی', 'sarResidDate': '',
|
||||
}),
|
||||
('customerHesabdari', {'c_id': c.id}, GET, None),
|
||||
('customerSarresidh', {'c_id': c.id}, GET, None),
|
||||
('customerHesabdariAdd', {'c_id': c.id}, POST, {
|
||||
'price': '1000', 'type': 'debit', 'typePy': 'nasb',
|
||||
'desc': 'سند دودی', 'nahvePardakhat': 'نقد',
|
||||
}),
|
||||
# --- JSON ---
|
||||
('productsJson', {}, GET, None),
|
||||
('ActionJson', {'action_id': self.action.id}, GET, None),
|
||||
('CustomerJson', {'c_id': c.id}, GET, None),
|
||||
('productMojodi', {'p_id': self.product.id}, GET, None),
|
||||
# --- انبار ---
|
||||
('stores', {}, GET, None),
|
||||
('store', {'s_id': self.store.id}, GET, None),
|
||||
('operations', {'s_id': self.store.id}, GET, None),
|
||||
('operationProduct', {'s_id': self.store.id, 'p_id': self.product.id}, GET, None),
|
||||
('newOperation', {'s_id': self.store.id, 'p_id': self.product.id}, GET, None),
|
||||
('newStore', {}, POST, {'storeName': 'انبار دودی'}),
|
||||
('store_transfers', {'s_id': self.store.id}, GET, None),
|
||||
('process_transfer_action', {'transfer_id': self.transfer.id}, POST, {'action': 'invalid'}),
|
||||
('productAdd', {}, POST, {
|
||||
'namePrd': 'کالای دودی', 'category': self.category.id,
|
||||
'price': '1000', 'mojodiAval': '5', 'storeroom': self.store.id,
|
||||
}),
|
||||
('productEdit', {}, POST, {
|
||||
'prd_code': self.product.id, 'namePrd': 'کالای ویرایششده',
|
||||
'category': self.category.id, 'price': '2500',
|
||||
}),
|
||||
('operation', {}, POST, {
|
||||
'prd_id': self.product.id, 'actionType': 'vorod',
|
||||
'tedad': '3', 'azanbar': self.store.id, 'desc': 'ورود دودی',
|
||||
}),
|
||||
('productDel', {'p_id': self.spare_product.id, 's_id': self.store.id}, GET, None),
|
||||
('customerEdit', {'c_id': c.id}, POST, {
|
||||
'an_name': 'مشتری ویرایشدودی', 'an_address': 'کرج',
|
||||
'installDate': '1400/02/02', 'idnCode': '222',
|
||||
'phone_titles': ['موبایل'], 'phones': ['09120000001'],
|
||||
}),
|
||||
# --- اقدامات و نظرسنجی ---
|
||||
('rezayat', {}, GET, None),
|
||||
('rezayatEdit', {'ac_id': self.action.id}, POST, {'csGrade': '20', 'csDesc': 'عالی'}),
|
||||
('entezar', {}, GET, None),
|
||||
('ActionList', {}, GET, None),
|
||||
('ActionListToday', {}, GET, None),
|
||||
('ActionListUserToday', {'u_id': self.user.id}, GET, None),
|
||||
('ActionListAll', {}, GET, None),
|
||||
# --- چک و سررسید ---
|
||||
('passCheck', {'articl_id': self.sarresid.id}, POST, {'message': 'وصول شد'}),
|
||||
('update_check_info', {'sarresid_id': self.sarresid.id}, POST, {
|
||||
'check_row_number': '1', 'check_sayyad_id': '1234567890123456',
|
||||
'check_owner_name': 'مالک', 'check_owner_phone': '09120000000',
|
||||
}),
|
||||
('addSarresid', {'cs_id': c.id}, POST, {
|
||||
'n_sarresidDateTime': ms, 'n_sarresidPrice': '1000',
|
||||
'n_sarresidType': 'nesiye',
|
||||
}),
|
||||
('pardakhtNesiye', {'cs_id': c.id}, POST, {
|
||||
'next_sarresidDateTime': ms, 'next_sarresidPrice': '100',
|
||||
'sr_articlID': self.sarresid.id, 'nahvePardakht': 'naghd',
|
||||
}),
|
||||
('api_check_status_inquiry', {}, GET, None),
|
||||
('api_check_sabt_inquiry', {}, GET, None),
|
||||
# در انتهای لیست: خروج از حساب، چون بعد از آن تمام درخواستها بیاعتبار میشوند
|
||||
('p_logout', {}, GET, None),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------ tests
|
||||
|
||||
def test_every_endpoint_responds_without_server_error(self):
|
||||
failures = []
|
||||
for name, kwargs, method, data in self._endpoints():
|
||||
url = self._url(name, **kwargs)
|
||||
with self.subTest(endpoint=name):
|
||||
# HTTP_REFERER لازم است چون چند ویو با redirect(request.META['HTTP_REFERER'])
|
||||
# بازمیگردند و مرورگر واقعی همیشه آن را میفرستد.
|
||||
headers = {'HTTP_REFERER': '/panel/'}
|
||||
if method == GET:
|
||||
response = self.client.get(url, **headers)
|
||||
else:
|
||||
response = self.client.post(url, data or {}, **headers)
|
||||
|
||||
if response.status_code not in ACCEPTABLE:
|
||||
failures.append((name, response.status_code, url))
|
||||
|
||||
self.assertEqual(
|
||||
failures, [],
|
||||
msg=(
|
||||
"این اندپوینتها پاسخ غیرمنتظره (احتمالاً خطای ۵۰۰ ناشی از import جاافتاده) "
|
||||
f"دادهاند: {failures}"
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user