Files
guilan-ace-backend/apps/payments/tests/integration/test_payments.py
Amirhossein Khalili 6c5fadef2f feat: derive event lifecycle status from dates and flags
Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
2026-10-03 12:46:16 +03:30

473 lines
24 KiB
Python

import pytest
import json
import redis
from datetime import timedelta
from kombu.exceptions import OperationalError
from django.utils import timezone
from core.authentication import create_jwt_token
from apps.events.models import Event, Registration
from apps.payments.models import Payment, DiscountCode
from apps.users.models import User
@pytest.mark.django_db
@pytest.mark.integration
class TestPaymentsAPIIntegrationTests:
password = 'PaymentPass!123'
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='pay_user', email='pay.user@example.com', mobile='09123456789', password=cls.password)
cls.user.is_email_verified = True
cls.user.save(update_fields=['is_email_verified'])
def setup_method(self):
type(self).setUpTestData()
now = timezone.now()
self.event = Event.objects.create(title='Pay Event', description='Payment event', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=2), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), slug='pay-event', price=50000, capacity=10, is_published=True)
self.token = create_jwt_token(self.user)
def _headers(self):
return {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
def _create_paid_event(self):
now = timezone.now()
return Event.objects.create(title='Paid Event', description='Paid', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), slug=f'paid-{now.timestamp()}', price=20000, capacity=5, is_published=True)
def _create_discount_code(self, event):
code = DiscountCode.objects.create(code='DISC50', value=50, is_active=True)
code.applicable_events.add(event)
return code
def test_create_payment_for_free_event(self):
now = timezone.now()
free = Event.objects.create(title='Free', description='Zero', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), slug='free-event', price=0, capacity=10, is_published=True)
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': free.id, 'description': 'Free registration'}), content_type='application/json', **self._headers())
assert response.status_code == 200
data = response.json()
assert data['amount'] == 0
assert data['start_pay_url'] is None
def test_create_payment_with_discount(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post')
mock_response = mocker.Mock()
mock_response.json.return_value = {'data': {'code': 100, 'authority': 'AUTH'}}
mock_post.return_value = mock_response
code = self._create_discount_code(self.event)
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Pay with discount', 'discount_code': code.code}), content_type='application/json', **self._headers())
assert response.status_code == 200
payload = response.json()
assert payload['discount_amount'] == self.event.price // 2
assert payload['amount'] == self.event.price // 2
assert 'start_pay_url' in payload
payment = Payment.objects.get(user=self.user, event=self.event)
assert payment.discount_code == code
def test_create_payment_reuses_an_active_attempt(self, mocker):
gateway = mocker.patch('apps.payments.api.views.requests.post')
gateway.return_value.json.return_value = {'data': {'code': 100, 'authority': 'AUTH-REUSED'}}
first = self.client.post(
'/api/payments/create',
data=json.dumps({
'event_id': self.event.id,
'description': 'First tap',
'idempotency_key': 'b0b849d1-8d8f-4f0d-94b6-7b60876f1501',
}),
content_type='application/json',
**self._headers(),
)
second = self.client.post(
'/api/payments/create',
data=json.dumps({
'event_id': self.event.id,
'description': 'Repeated tap',
'idempotency_key': 'b5f06335-1301-48c2-b504-f009c8e087d3',
}),
content_type='application/json',
**self._headers(),
)
assert first.status_code == second.status_code == 200
assert first.json()['payment_id'] == second.json()['payment_id']
assert first.json()['start_pay_url'] == second.json()['start_pay_url']
assert Payment.objects.filter(user=self.user, event=self.event).count() == 1
gateway.assert_called_once()
def test_create_payment_replays_same_key_after_gateway_timeout(self, mocker):
gateway = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('timeout'))
payload = {
'event_id': self.event.id,
'description': 'Checkout retry',
'idempotency_key': 'ed7e1426-320d-4faf-b808-7ad63e73477c',
}
first = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers())
second = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers())
assert first.status_code == second.status_code == 200
assert first.json()['status'] == second.json()['status'] == 'pending'
assert first.json()['payment_id'] == second.json()['payment_id']
assert Payment.objects.filter(user=self.user, event=self.event).count() == 1
gateway.assert_called_once()
def test_gateway_server_error_is_not_treated_as_a_definitive_failure(self, mocker):
gateway_response = mocker.Mock(status_code=503)
gateway_response.json.return_value = {"errors": {"code": -9}}
mocker.patch("apps.payments.api.views.requests.post", return_value=gateway_response)
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Gateway server error"}),
content_type="application/json",
**self._headers(),
)
assert response.status_code == 200
assert response.json()["status"] == "pending"
assert Payment.objects.filter(
user=self.user,
event=self.event,
status=Payment.OrderStatusChoices.PENDING,
authority__isnull=True,
).exists()
def test_definitive_gateway_rejection_allows_a_new_attempt(self, mocker):
gateway = mocker.patch('apps.payments.api.views.requests.post')
gateway.side_effect = [
mocker.Mock(**{'json.return_value': {'errors': {'code': -9}}}),
mocker.Mock(**{'json.return_value': {'data': {'code': 100, 'authority': 'AUTH-RETRY'}}}),
]
rejected = self.client.post(
'/api/payments/create',
data=json.dumps({
'event_id': self.event.id,
'description': 'Rejected attempt',
'idempotency_key': '060826cf-b8c0-4828-b0f2-d37c0f2678dd',
}),
content_type='application/json',
**self._headers(),
)
retried = self.client.post(
'/api/payments/create',
data=json.dumps({
'event_id': self.event.id,
'description': 'New attempt',
'idempotency_key': '865f3c43-2888-4fd0-8706-1c2ae60cb5f6',
}),
content_type='application/json',
**self._headers(),
)
assert rejected.status_code == retried.status_code == 200
assert rejected.json()['status'] == 'failed'
assert retried.json()['status'] == 'pending'
assert Payment.objects.filter(user=self.user, event=self.event).count() == 2
assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.FAILED).count() == 1
assert gateway.call_count == 2
def test_callback_success_marks_paid(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post')
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH123')
mock_resp = mocker.Mock()
mock_resp.json.return_value = {'data': {'code': 100, 'ref_id': 'REF', 'card_pan': '123', 'card_hash': 'ABC'}}
mock_post.return_value = mock_resp
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert 'status=' not in response.url
def test_callback_redirects_if_payment_notification_redis_is_unavailable(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post')
self.user.is_mobile_verified = True
self.user.save(update_fields=['is_mobile_verified'])
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority='AUTH-REDIS-DOWN',
)
mock_post.return_value.json.return_value = {
'data': {'code': 100, 'ref_id': 'REF-REDIS-DOWN'}
}
mocker.patch(
'apps.payments.api.views.notify_user',
side_effect=redis.exceptions.ConnectionError('Redis unavailable'),
)
mocker.patch(
'apps.payments.api.views.send_critical_sms.delay',
side_effect=OperationalError('Celery broker unavailable'),
)
response = self.client.get(
'/api/payments/callback',
{'Authority': payment.authority, 'Status': 'OK'},
)
payment.refresh_from_db()
assert response.status_code == 302
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert payment.status == Payment.OrderStatusChoices.PAID
def test_callback_failure_redirects_failed(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post')
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH456')
mock_resp = mocker.Mock()
mock_resp.json.return_value = {'data': {'code': 101, 'ref_id': 'REF'}}
mock_post.return_value = mock_resp
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
def test_callback_missing_authority_redirects_to_safe_unknown_result(self):
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
assert response.status_code == 302
assert '/events?payment_callback=unmatched' in response.url
def test_callback_unknown_authority_redirects_to_safe_unknown_result(self):
response = self.client.get(
'/api/payments/callback',
{'Authority': 'AUTH-UNKNOWN', 'Status': 'OK'},
)
assert response.status_code == 302
assert '/events?payment_callback=unmatched' in response.url
assert 'AUTH-UNKNOWN' not in response.url
def test_callback_not_ok_is_verified_before_being_canceled(self, mocker):
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
gateway = mocker.patch('apps.payments.api.views.requests.post')
gateway.return_value.json.return_value = {'data': {'code': -51}}
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.CANCELED
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=self.event.price,
)
payment = Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority='AUTH-LATE-NOK',
)
mock_post = mocker.patch('apps.payments.api.views.requests.post')
mock_response = mocker.Mock()
mock_response.json.return_value = {'data': {'code': 100, 'ref_id': 'REF-LATE-NOK'}}
mock_post.return_value = mock_response
success_response = self.client.get(
'/api/payments/callback',
{'Authority': payment.authority, 'Status': 'OK'},
)
late_failure_response = self.client.get(
'/api/payments/callback',
{'Authority': payment.authority, 'Status': 'NOK'},
)
payment.refresh_from_db()
registration.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.verified_at is not None
assert registration.status == Registration.StatusChoices.CONFIRMED
expected_return = f'/events/{self.event.slug}/registration?registration_id={registration.ticket_id}'
assert expected_return in success_response.url
assert expected_return in late_failure_response.url
def test_create_payment_gateway_timeout_stays_pending(self, mocker):
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Gateway fail'}), content_type='application/json', **self._headers())
assert response.status_code == 200
assert response.json()['status'] == 'pending'
assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.PENDING).exists()
def test_verification_timeout_stays_pending_and_queues_reconciliation(self, mocker):
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-TIMEOUT')
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('gateway timeout'))
reconcile = mocker.patch('apps.payments.api.views.reconcile_payment.apply_async')
notify = mocker.patch('apps.payments.api.views.notify_user')
response = self.client.get('/api/payments/callback', {'Authority': payment.authority, 'Status': 'OK'})
payment.refresh_from_db()
assert response.status_code == 302
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert payment.status == Payment.OrderStatusChoices.PENDING
reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
notify.assert_not_called()
def test_payment_status_is_user_scoped_and_reflects_saved_state(self):
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-STATUS')
own = self.client.get(f'/api/payments/status/{payment.id}', **self._headers())
assert own.status_code == 200
assert own.json()['payment_id'] == payment.id
assert own.json()['status'] == 'pending'
assert own.json()['verification_state'] == 'awaiting_verification'
assert own.json()['verification_attempts'] == 0
assert own.json()['last_verification_at'] is None
assert own.json()['manual_review_at'] is None
other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password)
other_token = create_jwt_token(other)
forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}')
assert forbidden.status_code == 404
def test_payment_reference_lookup_requires_authentication_and_is_user_scoped(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
ref_id="OWNER-ONLY-REF",
)
unauthenticated = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF")
owned = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF", **self._headers())
other = User.objects.create_user(
username="other_ref_owner",
email="other-ref-owner@example.com",
mobile="09123456781",
password=self.password,
)
other_token = create_jwt_token(other)
forbidden = self.client.get(
"/api/payments/by-ref/OWNER-ONLY-REF",
HTTP_AUTHORIZATION=f"Bearer {other_token}",
)
assert unauthenticated.status_code == 401
assert owned.status_code == 200
assert owned.json()["payment_id"] == payment.id
assert forbidden.status_code == 404
def test_create_payment_when_already_paid(self):
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers())
assert response.status_code == 400
def test_registration_final_price_none_updates(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post')
registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.PENDING, final_price=None)
mock_response = mocker.Mock()
mock_response.json.return_value = {'data': {'code': 100, 'authority': 'AUTH'}}
mock_post.return_value = mock_response
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Update'}), content_type='application/json', **self._headers())
assert response.status_code == 200
registration.refresh_from_db()
if registration.final_price is None:
pytest.fail('final_price should be populated')
def test_coupon_check_success_and_errors(self):
code = DiscountCode.objects.create(code='PAYCO', value=20, is_active=True, type=DiscountCode.Type.PERCENT)
code.applicable_events.add(self.event)
missing = self.client.post('/api/payments/coupon/check', data=json.dumps({'event_id': self.event.id}), content_type='application/json', **self._headers())
assert missing.status_code == 422
invalid = self.client.post('/api/payments/coupon/check', data=json.dumps({'event_id': self.event.id, 'code': 'INVALID'}), content_type='application/json', **self._headers())
assert invalid.status_code == 404
success = self.client.post('/api/payments/coupon/check', data=json.dumps({'event_id': self.event.id, 'code': code.code}), content_type='application/json', **self._headers())
assert success.status_code == 200
assert 'final_price' in success.json()
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client
@pytest.fixture(autouse=True)
def _pytest_settings(self, settings):
settings.ZARINPAL_MERCHANT_ID = 'MID'
settings.ZARINPAL_REQUEST_URL = 'https://zarinpal/request'
settings.ZARINPAL_STARTPAY = 'https://zarinpal/start/'
settings.ZARINPAL_VERIFY_URL = 'https://zarinpal/verify'
settings.ZARINPAL_CALLBACK_URL = 'https://frontend/callback'
@pytest.mark.django_db
@pytest.mark.integration
def test_my_payments_are_user_scoped_and_omit_gateway_identifiers(client, user_factory, auth_headers, event_factory):
current_user = user_factory(username="payment_history_owner")
other_user = user_factory(username="payment_history_other")
own_event = event_factory(slug="payment-history-own", title="My workshop")
other_event = event_factory(slug="payment-history-other", title="Someone else's workshop")
Payment.objects.create(
user=current_user,
event=own_event,
base_amount=120000,
discount_amount=20000,
amount=100000,
status=Payment.OrderStatusChoices.PAID,
authority="internal-authority-owner",
ref_id="internal-reference-owner",
card_pan="6037-****-****-1234",
card_hash="internal-card-hash",
)
Payment.objects.create(
user=other_user,
event=other_event,
base_amount=50000,
amount=50000,
status=Payment.OrderStatusChoices.PENDING,
authority="internal-authority-other",
)
response = client.get("/api/payments/me", **auth_headers(current_user))
assert response.status_code == 200, response.content
payload = response.json()
assert len(payload) == 1
assert payload[0]["event"]["title"] == own_event.title
assert payload[0]["event"]["id"] == own_event.id
assert payload[0]["amount"] == 100000
assert payload[0]["discount_amount"] == 20000
assert payload[0]["status"] == "paid"
assert set(payload[0]) == {"payment_id", "event", "base_amount", "discount_amount", "amount", "status", "refund_status", "created_at", "paid_at"}
assert payload[0]["payment_id"] == Payment.objects.get(user=current_user, event=own_event).id
assert "internal-authority-owner" not in response.content.decode()
assert "internal-reference-owner" not in response.content.decode()
assert "internal-card-hash" not in response.content.decode()
assert client.get("/api/payments/me").status_code == 401
@pytest.mark.django_db
@pytest.mark.integration
def test_my_payments_pagination_is_user_scoped(client, user_factory, auth_headers, event_factory):
current_user = user_factory(username="payment_paging_owner")
other_user = user_factory(username="payment_paging_other")
event = event_factory(slug="payment-paging-event")
for index in range(12):
Payment.objects.create(
user=current_user,
event=event,
base_amount=1000 + index,
amount=1000 + index,
status=Payment.OrderStatusChoices.PAID,
)
Payment.objects.create(
user=other_user,
event=event,
base_amount=9000,
amount=9000,
status=Payment.OrderStatusChoices.PAID,
)
first_page = client.get("/api/payments/me/paginated", {"page": 1}, **auth_headers(current_user))
second_page = client.get("/api/payments/me/paginated", {"page": 2}, **auth_headers(current_user))
assert first_page.status_code == 200, first_page.content
assert first_page.json()["count"] == 12
assert first_page.json()["page_size"] == 10
assert first_page.json()["total_pages"] == 2
assert len(first_page.json()["results"]) == 10
assert len(second_page.json()["results"]) == 2
assert all(item["event"]["slug"] == event.slug for item in first_page.json()["results"])
assert "authority" not in first_page.json()["results"][0]