From 67cbe0560927e6d66e1318dda71d4c384b60adb6 Mon Sep 17 00:00:00 2001 From: Zareian Date: Fri, 2 Oct 2026 13:07:08 +0330 Subject: [PATCH] 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 --- ai_tutor/tests_credit_concurrency.py | 73 ++++++++++++++++++++++++++++ docs/ai-cost-and-guardrails.md | 5 +- subscription/tests_packs.py | 3 +- 3 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 ai_tutor/tests_credit_concurrency.py diff --git a/ai_tutor/tests_credit_concurrency.py b/ai_tutor/tests_credit_concurrency.py new file mode 100644 index 0000000..c785159 --- /dev/null +++ b/ai_tutor/tests_credit_concurrency.py @@ -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) diff --git a/docs/ai-cost-and-guardrails.md b/docs/ai-cost-and-guardrails.md index 5778145..9a9617a 100644 --- a/docs/ai-cost-and-guardrails.md +++ b/docs/ai-cost-and-guardrails.md @@ -57,8 +57,9 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa سوال از کجا پرداخت شد (`free` / `credit` / `none`). - **دفترِ اعتبار** `subscription.QuestionCredit`: هر خرید یک ردیفِ مثبت دارد (هر سفارش فقط یک بار، با قیدِ یکتا) و هر سوال یک ردیفِ ۱−. کم شدن داخلِ همان تراکنشی است که ردیفِ کاربر را قفل می‌کند. دو - سوالِ هم‌زمان با یک اعتبار آزموده نشده است؛ جلوگیری به آن قفل و `transaction_mode: IMMEDIATE`ِ - SQLite سپرده شده. + سوالِ هم‌زمان با یک اعتبار، و یک Idempotency-Key که هم‌زمان دو بار برسد، از ۲۰۲۶-۱۰-۰۲ با دو thread + واقعی آزموده می‌شوند (`ai_tutor/tests_credit_concurrency.py`): یک جواب و یک اعتبار. بدونِ + `transaction_mode: IMMEDIATE`ِ SQLite همین تست ۵۰۰ می‌گیرد. روی Postgres سنجیده نشده. - **قلبِ نامحدودِ بسته** در `HeartsPass` است، جدا از `Subscription`، تا خریدِ بسته اشتراکِ کسی را عوض نکند. از پایانِ کارتِ قبلی ادامه پیدا می‌کند. `HEARTS_ENFORCED` حالا پیش‌فرض روشن است، پس قلبِ صفر واقعاً جلوی جواب را می‌گیرد. diff --git a/subscription/tests_packs.py b/subscription/tests_packs.py index d67969e..68c930f 100644 --- a/subscription/tests_packs.py +++ b/subscription/tests_packs.py @@ -3,8 +3,7 @@ راه‌های شکستن که امتحان می‌شوند: اعتبارِ دوباره برای تأییدِ تکراریِ پرداخت، عوض شدنِ اشتراکِ فعالِ کسی که بسته می‌خرد، خرج شدنِ اعتبار برای سوالی که به API نرسید یا جوابش ساخته نشد، و قلبِ هدیه که -در سرور اثری نداشته باشد. هم‌زمانیِ دو سوال با یک اعتبار این‌جا آزموده نشده؛ جلوگیری‌اش به قفلِ -ردیفِ کاربر و IMMEDIATEِ SQLite سپرده شده (ai_tutor.views، فازِ ۱). +در سرور اثری نداشته باشد. هم‌زمانیِ دو سوال با یک اعتبار در ai_tutor/tests_credit_concurrency.py است. """ from datetime import timedelta from unittest.mock import patch