This commit is contained in:
javad committed 2026-10-09 20:02:36 +03:30
1 parent 6f36db63a1
commit 250cdbab7f
42 files changed
+96363 -11699

No files matched your search

View File
Whitespace-only changes.
+107
View File
@@ -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
+84
View File
@@ -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())
+113
View File
@@ -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)
+201
View File
@@ -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)
+81
View File
@@ -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}")
+169
View File
@@ -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'))
+229
View File
@@ -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}"
),
)