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:
87
apps/payments/tests/integration/test_payment_admin.py
Normal file
87
apps/payments/tests/integration/test_payment_admin.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.events.models import Registration
|
||||
from apps.payments.models import DiscountCode, Payment
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def _send(client, method, path, headers, payload=None):
|
||||
kwargs = dict(headers)
|
||||
if payload is not None:
|
||||
kwargs.update(data=json.dumps(payload), content_type="application/json")
|
||||
return getattr(client, method)(path, **kwargs)
|
||||
|
||||
|
||||
def test_discount_code_admin_crud_and_restore(client, user_factory, auth_headers, event_factory):
|
||||
staff = user_factory(is_staff=True)
|
||||
admin = user_factory(is_staff=True, is_superuser=True)
|
||||
member = user_factory()
|
||||
event = event_factory(price=10000)
|
||||
path = "/api/payments/admin/discount-codes"
|
||||
payload = {"code": "pytest10", "type": "percent", "value": 10, "applicable_event_ids": [event.pk]}
|
||||
assert _send(client, "get", path, auth_headers(member)).status_code == 403
|
||||
assert _send(client, "post", path, auth_headers(member), payload).status_code == 403
|
||||
created = _send(client, "post", path, auth_headers(staff), payload)
|
||||
assert created.status_code == 201, created.content
|
||||
code = DiscountCode.objects.get(pk=created.json()["id"])
|
||||
assert code.code == "PYTEST10" and list(code.applicable_events.all()) == [event]
|
||||
assert _send(client, "post", path, auth_headers(staff), payload).status_code == 400
|
||||
assert _send(client, "get", path + "?search=PYTEST10", auth_headers(staff)).json()["count"] == 1
|
||||
changed = _send(client, "put", f"{path}/{code.pk}", auth_headers(staff), {**payload, "value": 20})
|
||||
assert changed.status_code == 200, changed.content
|
||||
code.refresh_from_db()
|
||||
assert code.value == 20
|
||||
assert _send(client, "delete", f"{path}/{code.pk}", auth_headers(staff)).status_code == 403
|
||||
assert _send(client, "delete", f"{path}/{code.pk}", auth_headers(admin)).status_code == 200
|
||||
assert DiscountCode.deleted_objects.filter(pk=code.pk).exists()
|
||||
assert _send(client, "post", f"{path}/{code.pk}/restore", auth_headers(admin)).status_code == 200
|
||||
assert DiscountCode.objects.filter(pk=code.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gateway_result,expected", [({"errors": {"code": -9}}, 502), (None, 502)])
|
||||
def test_payment_create_gateway_failures_leave_no_active_payment(client, user_factory, auth_headers, event_factory,
|
||||
mocker, settings, gateway_result, expected):
|
||||
settings.ZARINPAL_MERCHANT_ID = "test"
|
||||
settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request"
|
||||
user = user_factory()
|
||||
event = event_factory(price=25000)
|
||||
gateway = mocker.patch("apps.payments.api.views.requests.post")
|
||||
if gateway_result is None:
|
||||
gateway.side_effect = RuntimeError("offline")
|
||||
else:
|
||||
gateway.return_value.json.return_value = gateway_result
|
||||
response = _send(client, "post", "/api/payments/create", auth_headers(user),
|
||||
{"event_id": event.pk, "description": "Ticket"})
|
||||
assert response.status_code == expected
|
||||
assert not Payment.objects.filter(user=user, event=event).exists()
|
||||
assert Registration.objects.filter(user=user, event=event).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gateway_result", [None, {"data": {"code": -1}}])
|
||||
def test_payment_callback_verification_failures_notify_once(client, user_factory, event_factory, mocker,
|
||||
settings, gateway_result):
|
||||
settings.ZARINPAL_MERCHANT_ID = "test"
|
||||
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
||||
settings.FRONTEND_CALLBACK_URL = "https://frontend.example/result"
|
||||
user = user_factory()
|
||||
event = event_factory(price=25000)
|
||||
registration = Registration.objects.create(event=event, user=user, status=Registration.StatusChoices.PENDING)
|
||||
payment = Payment.objects.create(user=user, event=event, registration=registration, amount=25000,
|
||||
base_amount=25000, authority="AUTH-FAIL", status=Payment.OrderStatusChoices.PENDING)
|
||||
gateway = mocker.patch("apps.payments.api.views.requests.post")
|
||||
notify = mocker.patch("apps.payments.api.views.notify_user")
|
||||
if gateway_result is None:
|
||||
gateway.side_effect = RuntimeError("offline")
|
||||
else:
|
||||
gateway.return_value.json.return_value = gateway_result
|
||||
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
|
||||
assert response.status_code == 302 and "status=failed" in response.url
|
||||
payment.refresh_from_db()
|
||||
registration.refresh_from_db()
|
||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
||||
assert registration.status == Registration.StatusChoices.PENDING
|
||||
notify.assert_called_once()
|
||||
@@ -1,11 +1,9 @@
|
||||
import pytest
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from django.contrib.admin import AdminSite
|
||||
from apps.payments.admin import DiscountCodeAdmin
|
||||
from apps.payments.models import DiscountCode, Payment
|
||||
@@ -14,50 +12,32 @@ from apps.events.models import Event
|
||||
from apps.users.models import User
|
||||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
class PaymentTestMixin:
|
||||
|
||||
@staticmethod
|
||||
def _create_user(**overrides):
|
||||
data = {
|
||||
"username": f"user_{uuid.uuid4().hex[:6]}",
|
||||
"email": f"user_{uuid.uuid4().hex[:6]}@example.com",
|
||||
"password": "Test!1234",
|
||||
}
|
||||
data = {'username': f'user_{uuid.uuid4().hex[:6]}', 'email': f'user_{uuid.uuid4().hex[:6]}@example.com', 'mobile': f'09{uuid.uuid4().int % 1000000000:09d}', 'password': 'Test!1234'}
|
||||
data.update(overrides)
|
||||
return User.objects.create_user(**data)
|
||||
|
||||
@staticmethod
|
||||
def _create_event(**overrides):
|
||||
now = timezone.now()
|
||||
defaults = {
|
||||
"title": "Sample",
|
||||
"description": "Desc",
|
||||
"start_time": now,
|
||||
"end_time": now + timedelta(hours=2),
|
||||
"registration_start_date": now - timedelta(days=1),
|
||||
"registration_end_date": now + timedelta(days=5),
|
||||
"slug": f"event-{uuid.uuid4().hex[:6]}",
|
||||
"price": 100000,
|
||||
"capacity": 10,
|
||||
"status": Event.StatusChoices.PUBLISHED,
|
||||
}
|
||||
defaults = {'title': 'Sample', 'description': 'Desc', 'start_time': now, 'end_time': now + timedelta(hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=5), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'price': 100000, 'capacity': 10, 'status': Event.StatusChoices.PUBLISHED}
|
||||
defaults.update(overrides)
|
||||
return Event.objects.create(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def _discount_code(**overrides):
|
||||
defaults = {
|
||||
"code": f"CODE{uuid.uuid4().hex[:4]}",
|
||||
"value": 50,
|
||||
"is_active": True,
|
||||
"type": DiscountCode.Type.PERCENT,
|
||||
}
|
||||
defaults = {'code': f'CODE{uuid.uuid4().hex[:4]}', 'value': 50, 'is_active': True, 'type': DiscountCode.Type.PERCENT}
|
||||
defaults.update(overrides)
|
||||
return DiscountCode.objects.create(**defaults)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestDiscountCodeModelTests(PaymentTestMixin):
|
||||
|
||||
class DiscountCodeModelTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
def setup_method(self):
|
||||
self.event = self._create_event()
|
||||
self.user = self._create_user(is_email_verified=True)
|
||||
|
||||
@@ -65,124 +45,98 @@ class DiscountCodeModelTests(TestCase, PaymentTestMixin):
|
||||
event = self._create_event(price=0)
|
||||
code = self._discount_code()
|
||||
code.applicable_events.add(event)
|
||||
self.assertEqual(code.calculate_discount(event, self.user), (0, 0))
|
||||
assert code.calculate_discount(event, self.user) == (0, 0)
|
||||
|
||||
def test_inactive_raises_error(self):
|
||||
code = self._discount_code(is_active=False)
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_start_date_validation(self):
|
||||
code = self._discount_code(starts_at=timezone.now() + timedelta(days=1))
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_end_date_validation(self):
|
||||
code = self._discount_code(ends_at=timezone.now() - timedelta(days=1))
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_applicable_events_enforcement(self):
|
||||
code = self._discount_code()
|
||||
other_event = self._create_event()
|
||||
code.applicable_events.add(other_event)
|
||||
with self.assertRaises(HttpError):
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_min_amount_guard(self):
|
||||
code = self._discount_code(min_amount=200000)
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_usage_limit_total(self):
|
||||
code = self._discount_code(usage_limit_total=1)
|
||||
code.applicable_events.add(self.event)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
discount_amount=0,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
discount_code=code,
|
||||
)
|
||||
with self.assertRaises(HttpError):
|
||||
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, discount_amount=0, status=Payment.OrderStatusChoices.PAID, discount_code=code)
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_usage_limit_per_user(self):
|
||||
code = self._discount_code(usage_limit_per_user=1)
|
||||
code.applicable_events.add(self.event)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
discount_amount=0,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
discount_code=code,
|
||||
)
|
||||
with self.assertRaises(HttpError):
|
||||
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, discount_amount=0, status=Payment.OrderStatusChoices.PENDING, discount_code=code)
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_final_price_below_min_post_discount(self):
|
||||
event = self._create_event(price=15000)
|
||||
code = self._discount_code(value=80)
|
||||
code.applicable_events.add(event)
|
||||
with self.assertRaises(HttpError):
|
||||
with pytest.raises(HttpError):
|
||||
code.calculate_discount(event, self.user)
|
||||
|
||||
def test_fixed_discount_type(self):
|
||||
code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000)
|
||||
code.applicable_events.add(self.event)
|
||||
final, disc = code.calculate_discount(self.event, self.user)
|
||||
self.assertEqual(disc, 5000)
|
||||
self.assertEqual(final, self.event.price - 5000)
|
||||
assert disc == 5000
|
||||
assert final == self.event.price - 5000
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestPaymentModelAndResourceTests(PaymentTestMixin):
|
||||
|
||||
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
def setup_method(self):
|
||||
self.event = self._create_event()
|
||||
self.user = self._create_user(is_email_verified=True)
|
||||
|
||||
def test_payment_clean_validates_amount(self):
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=1000,
|
||||
amount=500,
|
||||
discount_amount=400,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
payment = Payment(user=self.user, event=self.event, base_amount=1000, amount=500, discount_amount=400, status=Payment.OrderStatusChoices.INIT)
|
||||
with pytest.raises(ValidationError):
|
||||
payment.full_clean()
|
||||
|
||||
def test_payment_resource_defers_user_event(self):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=1000,
|
||||
amount=1000,
|
||||
discount_amount=0,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
)
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=1000, amount=1000, discount_amount=0, status=Payment.OrderStatusChoices.INIT)
|
||||
resource = PaymentResource()
|
||||
user_cell = resource.fields["user"].widget.clean(self.user.username, None)
|
||||
self.assertEqual(user_cell, self.user)
|
||||
event_cell = resource.fields["event"].widget.clean(self.event.title, None)
|
||||
self.assertEqual(event_cell, self.event)
|
||||
user_cell = resource.fields['user'].widget.clean(self.user.username, None)
|
||||
assert user_cell == self.user
|
||||
event_cell = resource.fields['event'].widget.clean(self.event.title, None)
|
||||
assert event_cell == self.event
|
||||
|
||||
def test_discount_resource_expands_events(self):
|
||||
resource = DiscountResource()
|
||||
widget = resource.fields["event"].widget
|
||||
self.assertEqual(widget.separator, "||")
|
||||
widget = resource.fields['event'].widget
|
||||
assert widget.separator == '||'
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestDiscountCodeAdminTests(PaymentTestMixin):
|
||||
|
||||
class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
def setup_method(self):
|
||||
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
|
||||
|
||||
def test_deactivate_codes_action(self):
|
||||
@@ -191,4 +145,4 @@ class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
|
||||
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
|
||||
self.admin.deactivate_codes(request, queryset)
|
||||
code.refresh_from_db()
|
||||
self.assertFalse(code.is_active)
|
||||
assert not code.is_active
|
||||
@@ -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'
|
||||
|
||||
21
apps/payments/tests/unit/test_schemas.py
Normal file
21
apps/payments/tests/unit/test_schemas.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from apps.payments.api.schemas import CreatePaymentIn, DiscountCodeWriteSchema
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_payment_request_requires_event_and_description():
|
||||
with pytest.raises(ValidationError):
|
||||
CreatePaymentIn(event_id=1)
|
||||
payload = CreatePaymentIn(event_id=1, description="Workshop")
|
||||
assert payload.discount_code is None
|
||||
|
||||
|
||||
def test_discount_write_defaults_to_active_percentage_code():
|
||||
payload = DiscountCodeWriteSchema(code="SAVE", value=10)
|
||||
assert payload.type == "percent"
|
||||
assert payload.is_active
|
||||
assert payload.applicable_event_ids == []
|
||||
Reference in New Issue
Block a user