""" تست دودی (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, 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 ('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}" ), )