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,298 @@
from django.test import TestCase
from unittest.mock import patch
# Create your tests here.
from django.db.models import F
from django.test import TestCase, TransactionTestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from payment.models import Order
from payment.services import IdempotencyKeyConflict, create_order
from .models import Plan, Subscription
class PurchaseIdempotencyRetryTests(TestCase):
"""Regression test: retrying an already-paid purchase must not regress
an active subscription back to pending."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self, idempotency_key):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_retry_of_paid_purchase_does_not_regress_active_subscription_to_pending(self):
subscription = Subscription.objects.create(
user_id=self.user.id, plan=self.plan, status='active',
)
order = Order.objects.create(
user_id=self.user.id,
subscription_id=subscription.id,
amount=self.plan.price,
currency=self.plan.currency,
status='paid',
provider='gateway',
idempotency_key='retry-key-1',
)
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
response = self._purchase('retry-key-1')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['orderId'], str(order.id))
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(Order.objects.filter(idempotency_key='retry-key-1').count(), 1)
def test_genuinely_new_purchase_still_starts_pending(self):
response = self._purchase('brand-new-key-1')
self.assertEqual(response.status_code, 200)
subscription = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(subscription.status, 'pending')
self.assertEqual(str(subscription.order_id), response.data['orderId'])
class PurchaseExternalCallNotUnderLockTests(TransactionTestCase):
"""Regression test: create_order must not run inside PurchaseView's
transaction.atomic block, since it will eventually call an external
payment gateway. Uses TransactionTestCase (not TestCase) because
TestCase wraps every test in its own outer atomic block, which would
make connection.in_atomic_block always True and hide this bug."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-lock-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def test_create_order_is_called_without_an_open_db_transaction(self):
from django.db import connection
with patch('subscription.views.create_order', wraps=create_order) as mocked_create_order:
def _assert_no_open_transaction(*args, **kwargs):
# Fails here if the view still calls create_order under its own lock.
assert not connection.in_atomic_block, (
'create_order must not be called while a DB transaction from '
'PurchaseView is open'
)
return create_order(*args, **kwargs)
mocked_create_order.side_effect = _assert_no_open_transaction
response = self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
mocked_create_order.assert_called_once()
class PurchaseOrderCreationFailureCleanupTests(TestCase):
"""Regression test: even though create_order runs outside the view's
lock, a failed order must still clean up the subscription correctly
(delete if freshly created, restore prior state if it already existed)."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-cleanup-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_failed_order_creation_deletes_freshly_created_subscription(self):
self.assertFalse(Subscription.objects.filter(user_id=self.user.id).exists())
with patch(
'subscription.views.create_order',
side_effect=IdempotencyKeyConflict('تست شکستِ create_order'),
):
response = self._purchase()
self.assertEqual(response.status_code, 409)
self.assertFalse(Subscription.objects.filter(user_id=self.user.id).exists())
def test_failed_order_creation_restores_existing_subscription_exactly(self):
annual_plan = Plan.objects.create(
title='سالانه', price=900000, currency='IRT', duration_days=365,
)
subscription = Subscription.objects.create(
user_id=self.user.id, plan=annual_plan, status='active',
)
subscription.order_id = 4242
subscription.save(update_fields=['order_id'])
with patch(
'subscription.views.create_order',
side_effect=IdempotencyKeyConflict('تست شکستِ create_order'),
):
response = self._purchase()
self.assertEqual(response.status_code, 409)
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.plan_id, annual_plan.id)
self.assertEqual(subscription.order_id, 4242)
class PurchaseFailureCleanupConcurrencyRegressionTests(TestCase):
"""Regression test: cleanup after a failed create_order must not
erase or overwrite a valid state written by a concurrent request in
between phase 1 and this cleanup."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-race-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
self.other_plan = Plan.objects.create(
title='سالانه', price=900000, currency='IRT', duration_days=365,
)
def _purchase(self):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_failure_delete_path_does_not_erase_concurrent_success_on_new_subscription(self):
self.assertFalse(Subscription.objects.filter(user_id=self.user.id).exists())
def _fail_after_concurrent_success(*args, **kwargs):
# Simulates a concurrent request succeeding between phase 1 and cleanup.
concurrent_order = Order.objects.create(
user_id=self.user.id,
amount=self.other_plan.price,
currency=self.other_plan.currency,
status='paid',
provider='gateway',
idempotency_key='concurrent-key-1',
)
Subscription.objects.filter(user_id=self.user.id).update(
status='active', plan_id=self.other_plan.id, order_id=concurrent_order.id,
)
raise IdempotencyKeyConflict('تست شکستِ create_order بعد از موفقیتِ هم‌زمان')
with patch('subscription.views.create_order', side_effect=_fail_after_concurrent_success):
response = self._purchase()
self.assertEqual(response.status_code, 409)
subscription = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.plan_id, self.other_plan.id)
def test_failure_restore_path_does_not_overwrite_concurrent_success_on_existing_subscription(self):
original_order_id = 4242
subscription = Subscription.objects.create(
user_id=self.user.id, plan=self.other_plan, status='active',
)
subscription.order_id = original_order_id
subscription.save(update_fields=['order_id'])
def _fail_after_concurrent_success(*args, **kwargs):
# Phase 1 already set status to 'pending'; a concurrent request
# now moves the subscription to a new active state.
concurrent_order = Order.objects.create(
user_id=self.user.id,
amount=self.plan.price,
currency=self.plan.currency,
status='paid',
provider='gateway',
idempotency_key='concurrent-key-2',
)
Subscription.objects.filter(user_id=self.user.id).update(
status='active', plan_id=self.plan.id, order_id=concurrent_order.id,
)
raise IdempotencyKeyConflict('تست شکستِ create_order بعد از موفقیتِ هم‌زمان')
with patch('subscription.views.create_order', side_effect=_fail_after_concurrent_success):
response = self._purchase()
self.assertEqual(response.status_code, 409)
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.plan_id, self.plan.id)
self.assertNotEqual(subscription.order_id, original_order_id)
class PurchaseIdenticalIntermediateStateRaceRegressionTests(TestCase):
"""Regression test: two concurrent purchases for the same plan can
reach an identical intermediate state (pending, same plan_id,
order_id=None), so cleanup can't distinguish them by field comparison
alone. Simulates a concurrent request B writing the same phase-1
state while request A is failing."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-race-identical-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self, idempotency_key):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_failed_request_does_not_delete_concurrent_requests_identical_pending_state(self):
def _simulate_concurrent_identical_purchase_then_fail(*args, **kwargs):
# Request B: concurrently repeats phase 1 (update_or_create + version
# bump) for the same user/plan, reaching the same intermediate state.
Subscription.objects.update_or_create(
user_id=self.user.id, defaults={'plan': self.plan, 'status': 'pending'},
)
b_subscription = Subscription.objects.get(user_id=self.user.id)
Subscription.objects.filter(pk=b_subscription.pk).update(version=F('version') + 1)
# Request A: its create_order fails.
raise IdempotencyKeyConflict('create_order failure for request A')
with patch(
'subscription.views.create_order',
side_effect=_simulate_concurrent_identical_purchase_then_fail,
):
response = self._purchase('key-A')
self.assertEqual(response.status_code, 409)
# Subscription now holds B's state and must survive A's failure cleanup
# even though A and B wrote identical status/plan_id/order_id.
subscription = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(subscription.status, 'pending')
self.assertEqual(subscription.plan_id, self.plan.id)
self.assertIsNone(subscription.order_id)
self.assertEqual(subscription.version, 2)