Files
khatibi/tests/test_view_smoke.py
T
2026-10-21 15:13:35 +03:30

373 lines
19 KiB
Python

"""
تست دودی (Smoke Test) تمام اندپوینت‌های اپ admin_panel.
چرا این تست حیاتی است؟
`python manage.py check` خطاهای `NameError`/`ImportError` را که داخل *بدنهٔ توابع*
ویو رخ می‌دهند نمی‌بیند. پس از تقسیم `views.py` به پکیج `views/`، تنها راه مطمئن
برای کشف importهای جاافتاده، فراخوانی واقعی هر ویو است. این ماژول دقیقاً همان کار
را انجام می‌دهد: هر مسیر urls.py با یک کاربر مجاز (superuser) فراخوانی می‌شود و
هیچ پاسخی نباید کد ۵۰۰ داشته باشد.
"""
import json
import re
from html.parser import HTMLParser
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from admin_panel.models import (Action, Article, Factor, PhoneNumber, Sarresid,
SMSQueue, SMSTemplate, 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,
)
# --- پیامک ---
cls.phone = PhoneNumber.objects.create(
customer=cls.customer, title='موبایل', phone_number='09120000000',
)
cls.customer.sms_primary_phones = '+989120000000'
cls.customer.save(update_fields=['sms_primary_phones'])
cls.sms_template = SMSTemplate.objects.get(sms_type='welcome')
cls.sms_queue = SMSQueue.objects.create(
customer=cls.customer, sms_type='welcome',
phone_number='+989120000000', message='دودی', status='failed',
)
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),
# --- پیامک ---
('sms_settings', {}, GET, None),
('sms_templates', {}, GET, None),
('sms_template_edit', {'t_id': self.sms_template.id}, POST, {
'title': 'عنوان دودی', 'text': 'متن دودی',
'timing_value': '2',
}),
('sms_customer_toggle', {'c_id': c.id}, POST, {
'sms_type': 'welcome', 'is_enabled': 'false',
}),
('sms_primary_toggle', {'c_id': c.id}, POST, {
'phone': '09120000000', 'is_primary': 'true',
}),
('sms_send_custom', {'c_id': c.id}, POST, {'text': 'پیامک دودی'}),
('customerSmsHistory', {'c_id': c.id}, GET, None),
('sms_queue', {}, GET, None),
('sms_report', {}, GET, None),
('sms_queue_retry', {'q_id': self.sms_queue.id}, POST, 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}"
),
)
class _DivBalanceParser(HTMLParser):
"""شمارندهٔ عمق <div> با احترام به محتوای <script>/<style>.
نکته: HTMLParser محتوای script/style را متن خام می‌داند، پس `<div>` داخل
رشتهٔ جاوااسکریپت به‌اشتباه شمرده نمی‌شود — همان چیزی که شمارش متنی ساده
اشتباه می‌کند.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0
self.min_depth = 0
self.depth_at_body = None
def handle_starttag(self, tag, attrs):
if tag == 'div':
self.depth += 1
def handle_endtag(self, tag):
if tag == 'div':
self.depth -= 1
self.min_depth = min(self.min_depth, self.depth)
elif tag == 'body':
self.depth_at_body = self.depth
class BaseTemplateStructureTests(TestCase):
"""ساختار HTML قالب پایه.
چرا؟ قالب `base.html` یک `<div class="">` باز می‌کرد که هرگز بسته نمی‌شد.
مرورگر آن را در EOF خودکار می‌بست، پس هیچ خطایی دیده نمی‌شد؛ ولی همهٔ
صفحات HTML نامعتبر تولید می‌کردند و بازسازی DOM توسط جاوااسکریپت و
پارسرهای سخت‌گیر می‌توانست بشکند.
"""
URLS = ['/panel/', '/panel/customers/', '/panel/entezar/', '/panel/rezayat/',
'/panel/hesabdari/', '/panel/report/']
def setUp(self):
self.user = make_user('structureAdmin', superuser=True)
self.client.force_login(self.user)
def _parsed_pages(self):
"""صفحاتی که در محیط تست کد ۲۰۰ می‌دهند را پارس می‌کند.
برخی صفحات روی دیتابیس خالی خطای داخلی می‌دهند (باگ مستقل، پوشش‌داده‌شده
در تست دودی)؛ آن‌ها اینجا کنار گذاشته می‌شوند. برای اینکه تست بی‌اثر
نشود، تعداد صفحات بررسی‌شده assert می‌شود.
"""
parsed = []
for url in self.URLS:
response = self.client.get(url)
if response.status_code != 200:
continue
parser = _DivBalanceParser()
parser.feed(response.content.decode('utf-8', 'replace'))
parsed.append((url, parser))
return parsed
def test_every_page_has_balanced_divs(self):
"""در پایان سند، هیچ <div> بازی نباید باقی بماند."""
parsed = self._parsed_pages()
self.assertGreaterEqual(len(parsed), 5,
msg='صفحات کافی برای بررسی ساختار رندر نشدند')
for url, parser in parsed:
with self.subTest(url=url):
self.assertEqual(
parser.depth_at_body, 0,
msg='در %s هنگام </body> تعداد %d تگ <div> بسته‌نشده ماند'
% (url, parser.depth_at_body),
)
def test_no_stray_closing_div(self):
"""عمق هرگز نباید منفی شود؛ یعنی </div> اضافی هم وجود ندارد."""
parsed = self._parsed_pages()
self.assertGreaterEqual(len(parsed), 5)
for url, parser in parsed:
with self.subTest(url=url):
self.assertGreaterEqual(parser.min_depth, 0,
msg='در %s تگ </div> اضافی وجود دارد' % url)
def test_base_template_comments_are_single_line(self):
"""کامنت‌های {# ... #} باید تک‌خطی باشند.
موتور قالب جنگو (۳.۲) الگوی کامنت را بدون re.DOTALL می‌شناسد؛ کامنت
چندخطی حذف نمی‌شود و عیناً در HTML چاپ می‌شود — در همین اصلاح، یک
`<div class="">` داخل کامنت چندخطی، به‌عنوان تگ واقعی به خروجی اضافه
شده بود و تگ بستن را می‌بلعید.
"""
import io
import os
templates_root = os.path.join('admin_panel', 'templates')
offenders = []
for base, _dirs, files in os.walk(templates_root):
for name in files:
if not name.endswith('.html'):
continue
path = os.path.join(base, name)
text = io.open(path, encoding='utf-8', errors='replace').read()
for match in re.finditer(r'\{#', text):
tail = text[match.start():match.start() + 500]
line_end = tail.find('\n')
end = line_end if line_end > -1 else len(tail)
if tail.find('#}', 0, end) == -1:
offenders.append('%s:%d' % (
path, text[:match.start()].count('\n') + 1))
self.assertEqual(offenders, [],
msg='کامنت چندخطی در این قالب‌ها حذف نمی‌شود: %s' % offenders)