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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user