373 lines
19 KiB
Python
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)
|