Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -1,3 +1,400 @@
from django.test import TestCase
from unittest.mock import patch
# Create your tests here.
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import AskIdempotency, Conversation, Message
from .views import generate_tutor_answer
class AskIdempotencyFollowUpsTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکس: _build_ask_response قبلاً برای بازسازیِ
suggestedFollowUps دوباره generate_tutor_answer رو صدا می‌زد — که با
یک AI provider واقعی یعنی یک فراخوانیِ واقعیِ دیگه به‌ازای هر replay.
حالا suggested_follow_ups در همون درخواستِ اولِ موفق ذخیره می‌شه و
replay بدونِ هیچ فراخوانیِ دیگری به generate_tutor_answer همون مقدار
رو برمی‌گردونه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key):
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک به‌علاوه یک چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_initial_request_stores_generated_follow_ups(self):
response = self._ask('ask-key-1')
self.assertEqual(response.status_code, 200)
record = AskIdempotency.objects.get(user_id=self.user.id, idempotency_key='ask-key-1')
self.assertEqual(record.suggested_follow_ups, response.data['suggestedFollowUps'])
self.assertTrue(record.suggested_follow_ups)
def test_idempotent_retry_returns_same_follow_ups_without_calling_generator_again(self):
first_response = self._ask('ask-key-2')
self.assertEqual(first_response.status_code, 200)
original_follow_ups = first_response.data['suggestedFollowUps']
# ⚠️ در مسیرِ replay، generate_tutor_answer نباید اصلاً صدا زده
# بشه؛ اگه صدا زده بشه، این patch باعثِ خطا می‌شه و تست fail
# می‌کنه.
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=AssertionError('generate_tutor_answer must not be called on idempotent replay'),
):
retry_response = self._ask('ask-key-2')
self.assertEqual(retry_response.status_code, 200)
self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups)
self.assertEqual(retry_response.data['answer'], first_response.data['answer'])
self.assertEqual(retry_response.data['messageId'], first_response.data['messageId'])
class AskAIGenerationFailureTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ اصلیِ این تسک: جداکردنِ فراخوانیِ
generate_tutor_answer از قفلِ ردیفِ کاربر نباید رفتارِ صحتیِ سیستم رو
خراب کنه. اگه generate_tutor_answer خطا بده (مثلاً چون یک AI provider
واقعی موقتاً در دسترس نیست)، نباید:
- هیچ Message یتیمی (پیامِ کاربر بدونِ پاسخ) در دیتابیس بمونه؛
- اسلاتِ کوتای روزانه برای همیشه مصرف‌شده حساب بشه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key=None):
headers = {}
if idempotency_key:
headers['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'دو به‌علاوه دو چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
**headers,
)
def test_generation_failure_leaves_no_orphan_message_and_returns_503(self):
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
response = self._ask()
self.assertEqual(response.status_code, 503)
self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id).count(), 0)
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
def test_generation_failure_does_not_consume_quota_slot(self):
# یک سوالِ ناموفق می‌فرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
# DAILY_AI_QUOTA سوالِ موفق پشتِ سرِ هم می‌فرستیم؛ همه باید ۲۰۰
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
from .views import DAILY_AI_QUOTA
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
failed_response = self._ask()
self.assertEqual(failed_response.status_code, 503)
for _ in range(DAILY_AI_QUOTA):
ok_response = self._ask()
self.assertEqual(ok_response.status_code, 200)
# این سوالِ اضافه باید رد بشه چون سقفِ ۱۵ (نه ۱۴) واقعاً پر شده.
throttled_response = self._ask()
self.assertEqual(throttled_response.status_code, 429)
def test_generation_failure_on_existing_conversation_keeps_conversation(self):
# وقتی conversationId از قبل وجود داره (متعلق به این کاربر)، در
# صورتِ شکستِ generate_tutor_answer فقط user_messageِ همین
# درخواست باید حذف بشه، نه خودِ conversation (که ممکنه پیام‌های
# دیگه‌ای هم داشته باشه).
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمی')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمی')
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک سوالِ جدید', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 503)
self.assertTrue(Conversation.objects.filter(pk=conversation.id).exists())
# فقط دو پیامِ قدیمی باید باقی مونده باشن؛ پیامِ کاربرِ رزروشده‌ی
# همین درخواستِ ناموفق باید پاک شده باشه.
self.assertEqual(conversation.messages.count(), 2)
class AskAIConcurrentIdempotencyKeyTests(TestCase):
"""
🔒⚛️ تستِ رگرسیونِ فیکسِ این تسک برای حالتِ خیلی نادرِ رقابتِ هم‌زمانِ
دو درخواست با دقیقاً همون Idempotency-Key، بعد از اینکه فراخوانیِ AI
از زیرِ قفلِ ردیفِ کاربر بیرون اومده. این تست به‌جای threadهای واقعی
(که روی SQLite ناپایدارن)، این رقابت رو به‌صورتِ قطعی/تکرارپذیر شبیه‌سازی
می‌کنه: داخلِ side_effectِ generate_tutor_answerِ درخواستِ «اول»، یک
درخواستِ HTTP کاملاً جداگانه (با همون کلید) فرستاده می‌شه که کاملاً
پردازش می‌شه و AskIdempotency رو زودتر می‌سازه (برنده)؛ درخواستِ
«اول» بعد از برگشتن از generate_tutor_answer باید در فازِ نهایی به
IntegrityError بخوره، رزروِ خودش رو پاک کنه، و نتیجه‌ی همون برنده رو
برگردونه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def test_losing_request_cleans_up_and_returns_winners_result(self):
key = 'race-key-1'
inner_client = APIClient()
# ⚠️ چون generate_tutor_answer برای کلِ طولِ این with-block پچ
# شده، درخواستِ داخلیِ زیر هم به همین side_effect برمی‌خوره؛ برای
# جلوگیری از بازگشتِ بی‌نهایت، فقط اولین فراخوانی (درخواستِ
# بیرونی) درخواستِ داخلی رو می‌سازه؛ خودِ فراخوانیِ generate_tutor_
# answerِ درخواستِ داخلی (فراخوانیِ دوم) صرفاً یک پاسخِ قطعی
# برمی‌گردونه.
call_count = {'n': 0}
def side_effect(user_question, grade_id, lesson_id, context=None):
call_count['n'] += 1
if call_count['n'] == 1:
# این «درخواستِ داخلی» با همون idempotency key، در طولِ
# فراخوانیِ AIِ درخواستِ بیرونی (که قفلِ ردیفِ کاربر رو
# دیگه نگه نداره) کاملاً اجرا و کامل می‌شه — یعنی برنده‌ی
# رقابت می‌شه.
resp = inner_client.post(
'/api/ai/ask',
{'userQuestion': user_question},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=key,
)
assert resp.status_code == 200, resp.content
side_effect.inner_response = resp
return 'پاسخِ واقعی برای سوال', ['ادامه بده']
return 'پاسخِ دستیار برای درخواستِ داخلی', ['ادامه بده']
with patch('ai_tutor.views.generate_tutor_answer', side_effect=side_effect):
outer_response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ رقابتی'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=key,
)
inner_response = side_effect.inner_response
self.assertEqual(outer_response.status_code, 200)
# هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجه‌ی برنده)
# اشاره کنن.
self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId'])
self.assertEqual(outer_response.data['answer'], inner_response.data['answer'])
# فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه.
self.assertEqual(
AskIdempotency.objects.filter(user_id=self.user.id, idempotency_key=key).count(), 1
)
# فقط یک پیامِ کاربر و یک پیامِ دستیار باید نهایتاً باقی مونده
# باشن؛ پیامِ رزروشده‌ی درخواستِ بازنده باید پاک شده باشه (یعنی
# کوتای روزانه فقط یک بار مصرف شده، نه دوبار).
self.assertEqual(
Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 1
)
self.assertEqual(
Message.objects.filter(conversation__user_id=self.user.id, role='assistant').count(), 1
)
class AskAIConversationApiContractTests(TestCase):
"""
🔒 تستِ رگرسیونِ این تسک: اضافه‌شدنِ مرزِ context به generate_tutor_answer
نباید هیچ تغییری در قراردادِ API endpoint (POST /api/ai/ask) بده — نه
برای شروعِ مکالمه‌ی جدید (بدونِ conversationId)، نه برای ادامه‌ی
مکالمه‌ی موجود (با conversationId)، و نه در چک‌های مالکیت/احراز هویت.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.other_user = User.objects.create(device_id='device-contract-2', is_guest=True, name='')
self.other_token = issue_access_token(self.other_user.id)
def test_no_conversation_id_creates_new_conversation(self):
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سلام'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
conversations = Conversation.objects.filter(user_id=self.user.id)
self.assertEqual(conversations.count(), 1)
self.assertEqual(response.data['conversationId'], str(conversations.first().id))
def test_existing_conversation_id_continues_same_conversation(self):
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قبلی')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قبلی')
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ دوم', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['conversationId'], str(conversation.id))
# هنوز فقط همون یک conversation باید وجود داشته باشه؛ چیزِ جدیدی
# ساخته نشده.
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 1)
self.assertEqual(conversation.messages.count(), 4)
def test_conversation_id_belonging_to_another_user_is_rejected(self):
other_conversation = Conversation.objects.create(user_id=self.other_user.id)
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوال', 'conversationId': str(other_conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 404)
self.assertEqual(other_conversation.messages.count(), 0)
class GenerationBoundaryContextTests(TestCase):
"""
🏗️ تستِ رگرسیونِ اصلیِ این تسک: مرزِ تولیدِ پاسخ
(generate_tutor_answer(user_question, grade_id, lesson_id, context))
باید:
- همچنان gradeId/lessonId رو بگیره (metadataِ لازم برای RAG/Qdrantِ
آینده)، دقیقاً همون‌هایی که در بدنه‌ی API درخواست اومدن؛
- بتونه یک context اپلیکیشن‌-ساخته (رشته/شیء/لیستِ پیام) هم کنارِ اون‌ها
بگیره، بدونِ اینکه بدونه اون context چطور retrieve شده؛
و AskAIView (که هنوز لایه‌ی RAG/Qdrant رو پیاده نمی‌کنه) نباید خودش
تاریخچه‌ی خامِ مکالمه رو بسازه و به این مرز بفرسته.
"""
def test_generate_tutor_answer_accepts_grade_lesson_and_application_provided_context(self):
# مرز باید grade_id/lesson_id رو (به‌عنوانِ metadataِ RAGِ آینده) و
# context رو (چه رشته باشه، چه شیء/دیکشنری، چه لیستِ پیام‌های
# retrieve-شده) به‌عنوانِ آرگومان‌هایی مستقل کنارِ سوالِ کاربر قبول
# کنه.
answer_text, follow_ups = generate_tutor_answer(
'مشتق تابع sin(x) چیست؟',
7,
'42',
context={'retrieved_snippets': ['قاعده‌ی مشتق‌گیریِ توابعِ مثلثاتی']},
)
self.assertIn('مشتق تابع sin(x) چیست؟', answer_text)
self.assertTrue(follow_ups)
# بدونِ context هم (رفتارِ فعلی/پیش‌فرض) باید عادی کار کنه؛ context
# باید کاملاً اختیاری بمونه.
answer_text_no_context, _ = generate_tutor_answer('یک سوال بدون context', None, None)
self.assertIn('یک سوال بدون context', answer_text_no_context)
def test_ask_ai_view_passes_grade_and_lesson_id_from_request_to_generation_boundary(self):
# gradeId/lessonIdِ همون درخواستِ API باید دقیقاً همون‌طور که
# فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای
# RAG/Qdrantِ آینده‌ست، نه تاریخچه.
client = APIClient()
user = User.objects.create(device_id='device-boundary-grade-1', is_guest=True, name='')
token = issue_access_token(user.id)
with patch(
'ai_tutor.views.generate_tutor_answer',
wraps=generate_tutor_answer,
) as mocked_generate:
response = client.post(
'/api/ai/ask',
{
'userQuestion': 'مشتق چیست؟',
'gradeId': 10,
'lessonId': 'lesson-55',
},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
mocked_generate.assert_called_once_with('مشتق چیست؟', 10, 'lesson-55', None)
def test_ask_ai_view_does_not_send_conversation_history_to_generation_boundary(self):
# این تستِ کلیدیِ این تسکه: وقتی AskAIView مرزِ تولید رو صدا می‌زنه،
# نباید هیچ تاریخچه‌ی خامِ Message/Conversation (لیستی از پیام‌های
# قبلی، یا خودِ QuerySet پیام‌ها) بهش پاس بده — چون RAG هنوز اینجا
# پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال
# مجازن).
client = APIClient()
user = User.objects.create(device_id='device-boundary-1', is_guest=True, name='')
token = issue_access_token(user.id)
conversation = Conversation.objects.create(user_id=user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ اول')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ اول')
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ دوم')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ دوم')
with patch(
'ai_tutor.views.generate_tutor_answer',
wraps=generate_tutor_answer,
) as mocked_generate:
response = client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ جدید', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
mocked_generate.assert_called_once()
call_args, call_kwargs = mocked_generate.call_args
all_args = list(call_args) + list(call_kwargs.values())
# آرگومانِ اول باید فقط همین سوالِ تازه باشه، نه چیزِ دیگه‌ای.
self.assertEqual(call_args[0], 'سوالِ جدید')
# هیچ آرگومانی نباید حاویِ متنِ پیام‌های قدیمی یا خودِ آبجکت‌های
# Message/Conversation باشه — یعنی هیچ تاریخچه‌ی خامی به مرزِ تولید
# پاس داده نشده.
for arg in all_args:
self.assertNotIsInstance(arg, (Message, Conversation))
if isinstance(arg, str):
self.assertNotIn('قدیمی', arg)
# context (چهارمین آرگومان) در این تسک باید None باشه (RAG/Qdrant
# هنوز پیاده نشده)؛ gradeId/lessonId هم چون در این درخواست فرستاده
# نشدن، None هستن.
context_arg = call_args[3] if len(call_args) > 3 else call_kwargs.get('context')
self.assertIsNone(context_arg)