Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user