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:
2026-10-02 13:07:08 +03:30
parent fe658c697a
commit 67cbe05609
3 changed files with 77 additions and 4 deletions

View 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)