test: migrate backend suites to pytest with module coverage gate

Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL.
This commit is contained in:
2026-09-26 16:50:29 +03:30
parent 3ec28638d1
commit ff99130938
76 changed files with 3271 additions and 1357 deletions

View File

@@ -1,282 +1,137 @@
import pytest
import json
from datetime import timedelta
from unittest import mock
from django.test import TestCase, override_settings
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
@override_settings(
ZARINPAL_MERCHANT_ID="MID",
ZARINPAL_REQUEST_URL="https://zarinpal/request",
ZARINPAL_STARTPAY="https://zarinpal/start/",
ZARINPAL_VERIFY_URL="https://zarinpal/verify",
ZARINPAL_CALLBACK_URL="https://frontend/callback",
)
class PaymentsAPIIntegrationTests(TestCase):
password = "PaymentPass!123"
@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",
password=cls.password,
)
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"])
cls.user.save(update_fields=['is_email_verified'])
def setUp(self):
super().setUp()
self.event = Event.objects.create(
title="Pay Event",
description="Payment event",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=2),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="pay-event",
price=50000,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
def setup_method(self):
type(self).setUpTestData()
self.event = Event.objects.create(title='Pay Event', description='Payment event', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), slug='pay-event', price=50000, capacity=10, status=Event.StatusChoices.PUBLISHED)
self.token = create_jwt_token(self.user)
def _headers(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
return {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
def _create_paid_event(self):
return Event.objects.create(
title="Paid Event",
description="Paid",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=2),
slug=f"paid-{timezone.now().timestamp()}",
price=20000,
capacity=5,
status=Event.StatusChoices.PUBLISHED,
)
return Event.objects.create(title='Paid Event', description='Paid', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=2), slug=f'paid-{timezone.now().timestamp()}', price=20000, capacity=5, status=Event.StatusChoices.PUBLISHED)
def _create_discount_code(self, event):
code = DiscountCode.objects.create(
code="DISC50",
value=50,
is_active=True,
)
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):
free = Event.objects.create(
title="Free",
description="Zero",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="free-event",
price=0,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": free.id,
"description": "Free registration",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
free = Event.objects.create(title='Free', description='Zero', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), slug='free-event', price=0, capacity=10, status=Event.StatusChoices.PUBLISHED)
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()
self.assertEqual(data["amount"], 0)
self.assertIsNone(data["start_pay_url"])
assert data['amount'] == 0
assert data['start_pay_url'] is None
@mock.patch("apps.payments.api.views.requests.post")
def test_create_payment_with_discount(self, mock_post):
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
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(),
)
self.assertEqual(response.status_code, 200)
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()
self.assertEqual(payload["discount_amount"], self.event.price // 2)
self.assertEqual(payload["amount"], self.event.price // 2)
self.assertIn("start_pay_url", payload)
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)
self.assertEqual(payment.discount_code, code)
assert payment.discount_code == code
@mock.patch("apps.payments.api.views.requests.post")
def test_callback_success_marks_paid(self, mock_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 = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}}
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"},
)
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
self.assertTrue("status=success" in response.url)
assert payment.status == Payment.OrderStatusChoices.PAID
assert 'status=success' in response.url
@mock.patch("apps.payments.api.views.requests.post")
def test_callback_failure_redirects_failed(self, mock_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 = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}}
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"},
)
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
self.assertTrue("status=success" in response.url)
assert payment.status == Payment.OrderStatusChoices.PAID
assert 'status=success' in response.url
def test_callback_missing_authority_returns_error(self):
response = self.client.get("/api/payments/callback", {"Status": "OK"})
self.assertEqual(response.status_code, 400)
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
assert response.status_code == 400
def test_callback_not_ok_cancels(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="AUTH789",
)
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH789", "Status": "NOK"},
)
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')
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED)
self.assertIn("status=failed", response.url)
assert payment.status == Payment.OrderStatusChoices.CANCELED
assert 'status=failed' in response.url
@mock.patch("apps.payments.api.views.requests.post", side_effect=RuntimeError("down"))
def test_create_payment_gateway_failure(self, mock_post):
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": self.event.id,
"description": "Gateway fail",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 502)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
def test_create_payment_gateway_failure(self, mocker):
mock_post = 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 == 502
assert not Payment.objects.filter(user=self.user).exists()
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(),
)
self.assertEqual(response.status_code, 400)
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
@mock.patch("apps.payments.api.views.requests.post")
def test_registration_final_price_none_updates(self, mock_post):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=None,
)
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
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(),
)
self.assertEqual(response.status_code, 200)
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:
self.fail("final_price should be populated")
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 = 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()
# missing code
missing = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(missing.status_code, 422)
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client
# invalid code
invalid = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id, "code": "INVALID"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(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(),
)
self.assertEqual(success.status_code, 200)
self.assertIn("final_price", success.json())
@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'