The API moved to mathapi.tadrisino2.ir on 2026-09-26 (nginx server_name changed at 23:22) and api.math.tadrisino2.ir no longer resolves. The code default for PUBLIC_BASE_URL and the docs' curl examples still named the dead host, so every paymentUrl, the Zarinpal callback and the Bazaar redirect the backend builds pointed nowhere. Historical status notes in CLAUDE.md keep the old name on purpose. config/tests.py now reads the API host from PUBLIC_BASE_URL instead of a literal. 624 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
479 lines
22 KiB
Python
479 lines
22 KiB
Python
import importlib
|
||
import json
|
||
import logging
|
||
import os
|
||
from urllib.parse import urlsplit
|
||
from unittest.mock import patch
|
||
|
||
from django.conf import settings
|
||
from django.test import RequestFactory, SimpleTestCase, TestCase
|
||
from rest_framework.test import APIClient
|
||
|
||
from accounts.authentication import issue_access_token
|
||
from accounts.models import User
|
||
from config.middleware import APIRequestLoggingMiddleware
|
||
|
||
|
||
class SecretKeyFailClosedTests(SimpleTestCase):
|
||
"""
|
||
SECRET_KEY در پروداکشن هرگز دیفالتِ قابلحدسزدن ندارد — این هنوز برقرار است.
|
||
|
||
آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمیآمد، و
|
||
نتیجهاش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا میآورد
|
||
باید حدس میزد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی
|
||
میسازد و در فایلی کنارِ پروژه نگه میدارد — همانقدر غیرقابلِ حدس، بدونِ
|
||
یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید
|
||
تصادفی باشد، نه رشتهی هاردکدِ حالتِ توسعه.
|
||
|
||
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
|
||
فقط یکبار موقعِ import محاسبه میشه (نه چیزی که override_settings
|
||
بتونه toggle کنه)، ماژولِ config.settings رو با متغیرهای محیطیِ
|
||
کنترلشده reload میکنیم.
|
||
"""
|
||
|
||
allow_database_queries = True
|
||
|
||
def _reload_settings_with_env(self, env_overrides, env_removed=()):
|
||
from config import settings as settings_module
|
||
|
||
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
|
||
clean_env.update(env_overrides)
|
||
with patch.dict(os.environ, clean_env, clear=True):
|
||
importlib.reload(settings_module)
|
||
return settings_module
|
||
|
||
def tearDown(self):
|
||
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
|
||
# بقیهی تستها اثر نذاره.
|
||
from config import settings as settings_module
|
||
importlib.reload(settings_module)
|
||
|
||
def test_production_without_the_env_var_generates_a_real_random_key(self):
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': ''},
|
||
env_removed={'DJANGO_SECRET_KEY'},
|
||
)
|
||
key = settings_module.SECRET_KEY
|
||
self.addCleanup(
|
||
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||
)
|
||
self.assertFalse(settings_module.DEBUG)
|
||
self.assertNotEqual(
|
||
key,
|
||
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
|
||
'کلیدِ حالتِ توسعه هرگز نباید در پروداکشن استفاده شود',
|
||
)
|
||
self.assertGreaterEqual(len(key), 50)
|
||
|
||
def test_the_generated_key_survives_a_restart(self):
|
||
"""اگر هر بار کلیدِ تازه ساخته شود، همهی نشستها با هر ریاستارت باطل میشوند."""
|
||
first = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||
).SECRET_KEY
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||
)
|
||
self.addCleanup(
|
||
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||
)
|
||
self.assertEqual(first, settings_module.SECRET_KEY)
|
||
|
||
def test_production_configured_secret_key_used(self):
|
||
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
|
||
# ImproperlyConfiguredِ CACHE_URL رخ میده، نه رسیدن به assertionِ
|
||
# SECRET_KEY که این تست واقعاً میسنجه.
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={
|
||
'DJANGO_DEBUG': '',
|
||
'DJANGO_SECRET_KEY': 'prod-secret-xyz',
|
||
'CACHE_URL': 'redis://localhost:6379/0',
|
||
},
|
||
)
|
||
self.assertFalse(settings_module.DEBUG)
|
||
self.assertEqual(settings_module.SECRET_KEY, 'prod-secret-xyz')
|
||
|
||
def test_debug_mode_missing_secret_key_uses_local_fallback(self):
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': '1'},
|
||
env_removed={'DJANGO_SECRET_KEY'},
|
||
)
|
||
self.assertTrue(settings_module.DEBUG)
|
||
self.assertEqual(
|
||
settings_module.SECRET_KEY,
|
||
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
|
||
)
|
||
|
||
|
||
class CacheUrlFailClosedTests(SimpleTestCase):
|
||
"""
|
||
نبودنِ CACHE_URL در پروداکشن یک ضعفِ واقعیست: LocMemCache بینِ workerها
|
||
مشترک نیست و throttleها را کماثر میکند.
|
||
|
||
ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همینجا استثنا
|
||
پرتاب میشد و نتیجهاش از بیرون فقط 502 بود. حالا سرویس بالا میآید و
|
||
/api/health صریحاً هشدار میدهد. چیزی که باید تضمین شود این است که این
|
||
ضعف **بیصدا** نماند.
|
||
"""
|
||
|
||
allow_database_queries = True
|
||
|
||
def _reload_settings_with_env(self, env_overrides, env_removed=()):
|
||
from config import settings as settings_module
|
||
|
||
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
|
||
clean_env.update(env_overrides)
|
||
with patch.dict(os.environ, clean_env, clear=True):
|
||
importlib.reload(settings_module)
|
||
return settings_module
|
||
|
||
def tearDown(self):
|
||
from config import settings as settings_module
|
||
importlib.reload(settings_module)
|
||
|
||
def test_production_without_cache_url_boots_but_is_not_silent(self):
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
|
||
env_removed={'CACHE_URL'},
|
||
)
|
||
self.assertIn('LocMem', settings_module.CACHES['default']['BACKEND'])
|
||
|
||
# همان ضعف باید در /api/health دیده شود، وگرنه بیصدا میماند.
|
||
from config import health
|
||
|
||
with self.settings(DEBUG=False,
|
||
CACHES=settings_module.CACHES,
|
||
DEV_MASTER_OTP=None):
|
||
warnings = ' '.join(health._warnings())
|
||
self.assertIn('کش', warnings)
|
||
|
||
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': '1'},
|
||
env_removed={'CACHE_URL'},
|
||
)
|
||
self.assertTrue(settings_module.DEBUG)
|
||
self.assertEqual(
|
||
settings_module.CACHES['default']['BACKEND'],
|
||
'django.core.cache.backends.locmem.LocMemCache',
|
||
)
|
||
|
||
|
||
class APIRequestLoggingMiddlewareTests(TestCase):
|
||
"""
|
||
🔒 اثبات میانافزارِ جدیدِ لاگِ درخواستِ API (config.middleware.
|
||
APIRequestLoggingMiddleware): باید برای مسیرهای /api/ یک خطِ لاگِ خلاصه
|
||
(متد/مسیر/وضعیت/مدت/شناسهی کاربر) ثبت کنه، بدونِ اینکه هیچوقت بدنه/
|
||
هدر/سکرت رو لاگ کنه؛ و برای مسیرهای غیرِ /api/ (مثلِ /admin/) اصلاً
|
||
لاگ نکنه.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self.client = APIClient()
|
||
|
||
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
|
||
user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='')
|
||
token = issue_access_token(user.id)
|
||
|
||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||
response = self.client.post(
|
||
'/api/user/grade',
|
||
{'gradeId': 10},
|
||
format='json',
|
||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||
)
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(len(captured.records), 1)
|
||
message = captured.records[0].getMessage()
|
||
|
||
self.assertIn('method=POST', message)
|
||
self.assertIn('path=/api/user/grade', message)
|
||
self.assertIn('status=200', message)
|
||
self.assertIn(f'user_id={user.id}', message)
|
||
|
||
# هیچوقت نباید هیچ سکرتی در پیامِ لاگ باشه.
|
||
self.assertNotIn(token, message)
|
||
self.assertNotIn('Bearer', message)
|
||
self.assertNotIn('gradeId', message)
|
||
|
||
def test_anonymous_api_request_is_logged_with_null_user_id(self):
|
||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||
response = self.client.post(
|
||
'/api/auth/session',
|
||
{'type': 'guest', 'deviceId': 'device-mw-log-anon-1'},
|
||
format='json',
|
||
)
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(len(captured.records), 1)
|
||
message = captured.records[0].getMessage()
|
||
|
||
self.assertIn('method=POST', message)
|
||
self.assertIn('path=/api/auth/session', message)
|
||
self.assertIn('status=200', message)
|
||
self.assertIn('user_id=None', message)
|
||
|
||
def test_non_api_request_is_not_logged(self):
|
||
# /admin/ زیرِ /api/ نیست؛ میانافزار عمداً اینجور مسیرها رو لاگ
|
||
# نمیکنه تا لاگِ اضافه/بیفایده تولید نشه.
|
||
logger = logging.getLogger('config.middleware')
|
||
handler = logging.Handler()
|
||
records = []
|
||
handler.emit = records.append
|
||
logger.addHandler(handler)
|
||
try:
|
||
self.client.get('/admin/login/')
|
||
finally:
|
||
logger.removeHandler(handler)
|
||
|
||
self.assertEqual(records, [])
|
||
|
||
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
|
||
# میانافزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
|
||
# جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
|
||
# با logging.Formatter().format(record) دقیقاً همون مسیری که یک
|
||
# handlerِ واقعی طی میکنه رو صدا میزنیم (نه فقط getMessage()، که
|
||
# یه mismatchِ %%-args رو با exceptionِ خودش میپوشونه، نه fail واقعی).
|
||
request = RequestFactory().get('/api/some/failing/path')
|
||
|
||
def failing_get_response(inner_request):
|
||
raise ValueError('boom')
|
||
|
||
middleware = APIRequestLoggingMiddleware(failing_get_response)
|
||
|
||
with self.assertLogs('config.middleware', level='ERROR') as captured:
|
||
with self.assertRaises(ValueError):
|
||
middleware(request)
|
||
|
||
self.assertEqual(len(captured.records), 1)
|
||
record = captured.records[0]
|
||
self.assertEqual(record.levelname, 'ERROR')
|
||
|
||
formatted = logging.Formatter().format(record)
|
||
|
||
self.assertIn('method=GET', formatted)
|
||
self.assertIn('path=/api/some/failing/path', formatted)
|
||
self.assertIn('status=500', formatted)
|
||
self.assertIn('user_id=None', formatted)
|
||
|
||
|
||
class JSONLogFormatterTests(SimpleTestCase):
|
||
"""
|
||
🔒 اثبات فرمتکنندهی جدیدِ JSON (config.log_formatters.JSONLogFormatter)
|
||
که پروژه رو برای جمعآوریِ متمرکزِ لاگ (Loki/ELK-style) آماده میکنه:
|
||
خروجی باید یک JSON معتبر با فیلدهای timestamp/level/logger/message
|
||
باشه، و در صورتِ وجودِ exception، traceback هم بهعنوانِ فیلدِ
|
||
"exception" اضافه بشه.
|
||
"""
|
||
|
||
def _make_record(self, level=logging.INFO, message='hello world', exc_info=None):
|
||
return logging.LogRecord(
|
||
name='some.logger',
|
||
level=level,
|
||
pathname=__file__,
|
||
lineno=1,
|
||
msg=message,
|
||
args=(),
|
||
exc_info=exc_info,
|
||
)
|
||
|
||
def test_basic_record_is_formatted_as_valid_json_with_expected_fields(self):
|
||
from config.log_formatters import JSONLogFormatter
|
||
|
||
record = self._make_record(level=logging.INFO, message='api_request method=GET path=/api/x status=200')
|
||
formatted = JSONLogFormatter().format(record)
|
||
|
||
payload = json.loads(formatted)
|
||
self.assertEqual(payload['level'], 'INFO')
|
||
self.assertEqual(payload['logger'], 'some.logger')
|
||
self.assertEqual(payload['message'], 'api_request method=GET path=/api/x status=200')
|
||
self.assertIn('timestamp', payload)
|
||
self.assertNotIn('exception', payload)
|
||
|
||
def test_record_with_exception_includes_traceback_field(self):
|
||
from config.log_formatters import JSONLogFormatter
|
||
|
||
try:
|
||
raise ValueError('boom')
|
||
except ValueError:
|
||
import sys
|
||
exc_info = sys.exc_info()
|
||
|
||
record = self._make_record(level=logging.ERROR, message='something failed', exc_info=exc_info)
|
||
formatted = JSONLogFormatter().format(record)
|
||
|
||
payload = json.loads(formatted)
|
||
self.assertEqual(payload['level'], 'ERROR')
|
||
self.assertEqual(payload['message'], 'something failed')
|
||
self.assertIn('exception', payload)
|
||
self.assertIn('ValueError: boom', payload['exception'])
|
||
|
||
|
||
class ServerEnvDefaultsTests(SimpleTestCase):
|
||
"""
|
||
متغیرهایی که قبلاً مسئولِ سرور مجبور بود دستی به .env اضافه کند.
|
||
|
||
هر سهتا یک الگو داشتند: نبودشان یا سرویس را میخواباند یا یک مسیر را
|
||
بیسروصدا میشکست، و چون در .env.example هم نبودند، هر بار از نو کشف
|
||
میشدند. این تست نگهبانِ همان است — نه اینکه مقدارها «قشنگ»اند، بلکه
|
||
اینکه بدونِ هیچ متغیرِ محیطیای مقدارِ کارآمد دارند.
|
||
"""
|
||
|
||
# دامنهی API از PUBLIC_BASE_URL میآید، نه از یک رشتهی ثابت در تست.
|
||
HOST = urlsplit(settings.PUBLIC_BASE_URL).hostname
|
||
|
||
def test_the_api_domain_is_allowed_without_any_env_var(self):
|
||
self.assertIn(self.HOST, settings.DEFAULT_ALLOWED_HOSTS)
|
||
|
||
def test_an_incomplete_env_value_does_not_drop_our_domains(self):
|
||
"""
|
||
.env سرور از قبل DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد.
|
||
اگر مقدارِ موجود پیشفرض را کنار بزند، همان «Disallowed Host» قدیمی
|
||
برمیگردد و باز کسی باید دستی فایل را ویرایش کند.
|
||
"""
|
||
# settings فقط نامهای بزرگ را نشان میدهد؛ تابع از خودِ ماژول میآید.
|
||
from config.settings import _with_defaults
|
||
|
||
with patch.dict(os.environ, {'DJANGO_ALLOWED_HOSTS': 'localhost,127.0.0.1'}):
|
||
merged = _with_defaults('DJANGO_ALLOWED_HOSTS', settings.DEFAULT_ALLOWED_HOSTS)
|
||
self.assertIn(self.HOST, merged)
|
||
self.assertIn('localhost', merged, 'مقدارِ موجود نباید حذف شود')
|
||
|
||
def test_a_present_but_empty_value_still_falls_back(self):
|
||
"""کلید در .env هست ولی خالی — os.environ.get پیشفرض نمیدهد."""
|
||
with patch.dict(os.environ, {'PUBLIC_BASE_URL': ''}):
|
||
value = (os.environ.get('PUBLIC_BASE_URL', '').strip()
|
||
or 'https://mathapi.tadrisino2.ir')
|
||
self.assertTrue(value.startswith('https://'))
|
||
|
||
def test_admin_login_origin_is_trusted_without_any_env_var(self):
|
||
self.assertIn(f'https://{self.HOST}', settings.CSRF_TRUSTED_ORIGINS)
|
||
|
||
def test_the_deployed_frontend_may_call_the_api(self):
|
||
self.assertIn('https://math.tadrisino2.ir', settings.CORS_ALLOWED_ORIGINS)
|
||
|
||
def test_payment_urls_are_absolute_without_any_env_var(self):
|
||
"""خالی بودنِ PUBLIC_BASE_URL مسیرِ پرداخت را بیصدا میشکست."""
|
||
self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://'))
|
||
self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://'))
|
||
self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL)
|
||
|
||
|
||
class SecureCookieTests(SimpleTestCase):
|
||
"""
|
||
کوکیِ نشستِ ادمین جاییست که اعتبارنامهی واقعی وارد میشود؛ روی HTTP
|
||
نباید رد و بدل شود.
|
||
|
||
مثلِ بقیهی تستهای این فایل ماژول را دوباره بار میکنیم: این مقدارها در
|
||
زمانِ import محاسبه میشوند و اجرای تست خودش DEBUG را خاموش میکند، پس
|
||
خواندنِ settings در لحظه چیزِ گمراهکنندهای میدهد.
|
||
"""
|
||
|
||
allow_database_queries = True
|
||
|
||
def _reload(self, env_overrides, env_removed=()):
|
||
from config import settings as settings_module
|
||
|
||
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
|
||
clean_env.update(env_overrides)
|
||
with patch.dict(os.environ, clean_env, clear=True):
|
||
importlib.reload(settings_module)
|
||
return settings_module
|
||
|
||
def tearDown(self):
|
||
from config import settings as settings_module
|
||
importlib.reload(settings_module)
|
||
|
||
def test_cookies_are_secure_in_production(self):
|
||
module = self._reload(
|
||
{'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'x' * 60,
|
||
'CACHE_URL': 'redis://localhost:6379/0'},
|
||
)
|
||
self.assertFalse(module.DEBUG)
|
||
self.assertTrue(module.SESSION_COOKIE_SECURE)
|
||
self.assertTrue(module.CSRF_COOKIE_SECURE)
|
||
self.assertTrue(module.SESSION_COOKIE_HTTPONLY)
|
||
|
||
def test_cookies_stay_usable_over_plain_http_in_development(self):
|
||
"""با DEBUG روشن، کوکیِ امن یعنی ورود به ادمینِ محلی کار نمیکند."""
|
||
module = self._reload({'DJANGO_DEBUG': '1'})
|
||
self.assertTrue(module.DEBUG)
|
||
self.assertFalse(module.SESSION_COOKIE_SECURE)
|
||
|
||
def test_django_trusts_the_proxy_for_https(self):
|
||
"""بدونِ این، کوکیِ امن پشتِ nginx هرگز ست نمیشود و ورود میشکند."""
|
||
self.assertEqual(settings.SECURE_PROXY_SSL_HEADER,
|
||
('HTTP_X_FORWARDED_PROTO', 'https'))
|
||
|
||
|
||
class CorsDevPortTests(TestCase):
|
||
"""
|
||
پورتِ سرورِ توسعه نباید در فهرستِ CORS شمرده شود.
|
||
|
||
فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
|
||
بیسروصدا رفت روی ۵۱۷۴. فرانتکار خطای CORS گرفت، از سمتِ سرور هیچ ردی در
|
||
هیچ لاگی نبود، و چون همزمان با تغییراتِ بکاند شد به اشتباه گردنِ ما افتاد.
|
||
هزینهاش یک ویرایشِ .env و یک ریاستارتِ پروداکشن بود، برای چیزی که اصلاً
|
||
به پروداکشن ربطی نداشت.
|
||
"""
|
||
|
||
def _preflight(self, origin):
|
||
return self.client.options(
|
||
'/api/grades',
|
||
HTTP_ORIGIN=origin,
|
||
HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET',
|
||
)
|
||
|
||
def test_any_localhost_port_is_allowed_so_a_new_dev_port_never_breaks_again(self):
|
||
for origin in ('http://localhost:5173', 'http://localhost:5174',
|
||
'http://localhost:3000', 'http://127.0.0.1:8080'):
|
||
with self.subTest(origin=origin):
|
||
resp = self._preflight(origin)
|
||
self.assertEqual(
|
||
resp.headers.get('access-control-allow-origin'), origin)
|
||
|
||
def test_the_deployed_frontend_origin_still_works(self):
|
||
resp = self._preflight('https://math.tadrisino2.ir')
|
||
self.assertEqual(resp.headers.get('access-control-allow-origin'),
|
||
'https://math.tadrisino2.ir')
|
||
|
||
def test_a_stranger_origin_is_still_refused(self):
|
||
# الگو فقط localhost است، نه هر دامنهای؛ و «localhost» بهعنوانِ
|
||
# زیررشته در دامنهی دیگری نباید رد شود.
|
||
for origin in ('https://evil.example.com',
|
||
'http://localhost.evil.com',
|
||
'https://math.tadrisino2.ir.evil.com'):
|
||
with self.subTest(origin=origin):
|
||
resp = self._preflight(origin)
|
||
self.assertIsNone(resp.headers.get('access-control-allow-origin'))
|
||
|
||
def test_credentials_stay_off(self):
|
||
# توکنِ ما در هدر است نه کوکی؛ روشن کردنِ این فقط سطحِ حمله اضافه میکند
|
||
# و پذیرشِ هر پورتِ localhost را واقعاً خطرناک میکرد.
|
||
from django.conf import settings
|
||
self.assertFalse(settings.CORS_ALLOW_CREDENTIALS)
|
||
|
||
|
||
class ApiDocsRtlShellTests(TestCase):
|
||
"""
|
||
صفحههای مستنداتِ API باید پوستهی راستبهچپِ خودمان را داشته باشند.
|
||
|
||
بدونِ آن، Swagger متنِ فارسی را در یک بلوکِ چپبهراست میچیند و نتیجهاش
|
||
پریدنِ نقطهی جمله به ابتدای خط و برعکس شدنِ ستونهای جدول است. این تست
|
||
هست چون خرابیاش فقط با چشم دیده میشود: هیچ خطایی نمیدهد و هیچ تستِ
|
||
دیگری نمیگیردش.
|
||
"""
|
||
|
||
def test_swagger_ui_carries_the_rtl_shell(self):
|
||
html = self.client.get('/api/docs/').content.decode()
|
||
self.assertIn('direction: rtl', html)
|
||
# کد باید صریحاً چپبهراست بماند، وگرنه «Authorization: Bearer ...»
|
||
# در بلوکِ RTL جابهجا میشود.
|
||
self.assertIn('direction: ltr', html)
|
||
# قالبِ اصلیِ drf-spectacular باید دستنخورده زیرش باشد.
|
||
self.assertIn('swagger-ui-bundle', html)
|
||
|
||
def test_redoc_carries_the_rtl_shell(self):
|
||
html = self.client.get('/api/redoc/').content.decode()
|
||
self.assertIn('direction: rtl', html)
|