tests: two questions at once with one credit, with real threads
The claim that the user-row lock plus SQLite IMMEDIATE stops one credit being spent twice was untested. Two threads on separate connections now ask at the same time: one answer, one credit spent; the same Idempotency-Key arriving twice at once also costs one credit. Removing transaction_mode IMMEDIATE turns both red (500, database is locked). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
73
ai_tutor/tests_credit_concurrency.py
Normal file
73
ai_tutor/tests_credit_concurrency.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
اعتبارِ بسته زیرِ درخواستِ همزمان (بازبینیِ ۲۰۲۶-۱۰-۰۲).
|
||||
|
||||
پیش از این فقط ادعا بود که قفلِ ردیفِ کاربر و `transaction_mode: IMMEDIATE`ِ SQLite جلوی
|
||||
خرجِ دوبارهی یک اعتبار را میگیرند. اینجا دو thread واقعی، با دو اتصالِ جدا به دیتابیسِ فایلیِ
|
||||
تست، همزمان میپرسند. جوابِ دستیار ساختگی است و ۰٫۴ ثانیه طول میکشد تا دو درخواست واقعاً روی
|
||||
هم بیفتند. فقط روی SQLite سنجیده شده؛ بعد از مهاجرت به Postgres دوباره باید سبز شود.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
from django.test import TransactionTestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from subscription.models import QuestionCredit
|
||||
from subscription.services import credit_balance
|
||||
|
||||
from .models import Message
|
||||
|
||||
|
||||
def _slow_answer(*args, **kwargs):
|
||||
time.sleep(0.4)
|
||||
return 'جواب', ['بیشتر توضیح بده']
|
||||
|
||||
|
||||
@override_settings(AI_DAILY_QUOTA_FREE=0)
|
||||
class ConcurrentCreditTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007201', is_guest=False)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def race(self, *headers):
|
||||
codes = []
|
||||
barrier = threading.Barrier(len(headers))
|
||||
|
||||
def ask(extra):
|
||||
try:
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
barrier.wait()
|
||||
codes.append(client.post('/api/ai/ask', {'userQuestion': '۲ + ۲؟'}, format='json', **extra).status_code)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with patch('rag.services.config.rag_is_configured', return_value=False), \
|
||||
patch('ai_tutor.views.generate_tutor_answer', side_effect=_slow_answer):
|
||||
threads = [threading.Thread(target=ask, args=(h,)) for h in headers]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
return sorted(codes)
|
||||
|
||||
def test_two_questions_at_once_with_one_credit_get_one_answer(self):
|
||||
QuestionCredit.objects.create(user_id=self.user.id, delta=1, reason='grant')
|
||||
self.assertEqual(self.race({}, {}), [200, 429])
|
||||
self.assertEqual(credit_balance(self.user.id), 0)
|
||||
self.assertEqual(Message.objects.filter(role='assistant').count(), 1)
|
||||
|
||||
def test_the_same_idempotency_key_twice_at_once_costs_one_credit(self):
|
||||
QuestionCredit.objects.create(user_id=self.user.id, delta=2, reason='grant')
|
||||
key = {'HTTP_IDEMPOTENCY_KEY': 'same-key'}
|
||||
self.assertEqual(self.race(key, key), [200, 200])
|
||||
self.assertEqual(credit_balance(self.user.id), 1)
|
||||
self.assertEqual(Message.objects.filter(role='user').count(), 1)
|
||||
Reference in New Issue
Block a user