initial commit
This commit is contained in:
0
apps/payments/tests/__init__.py
Normal file
0
apps/payments/tests/__init__.py
Normal file
0
apps/payments/tests/integration/__init__.py
Normal file
0
apps/payments/tests/integration/__init__.py
Normal file
282
apps/payments/tests/integration/test_payments.py
Normal file
282
apps/payments/tests/integration/test_payments.py
Normal file
@@ -0,0 +1,282 @@
|
||||
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"
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(
|
||||
username="pay_user",
|
||||
email="pay.user@example.com",
|
||||
password=cls.password,
|
||||
)
|
||||
cls.user.is_email_verified = True
|
||||
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,
|
||||
)
|
||||
self.token = create_jwt_token(self.user)
|
||||
|
||||
def _headers(self):
|
||||
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,
|
||||
)
|
||||
|
||||
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):
|
||||
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)
|
||||
data = response.json()
|
||||
self.assertEqual(data["amount"], 0)
|
||||
self.assertIsNone(data["start_pay_url"])
|
||||
|
||||
@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"}}
|
||||
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)
|
||||
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)
|
||||
payment = Payment.objects.get(user=self.user, event=self.event)
|
||||
self.assertEqual(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"}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
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)
|
||||
|
||||
@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"}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
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)
|
||||
|
||||
def test_callback_missing_authority_returns_error(self):
|
||||
response = self.client.get("/api/payments/callback", {"Status": "OK"})
|
||||
self.assertEqual(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.refresh_from_db()
|
||||
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED)
|
||||
self.assertIn("status=failed", 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_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)
|
||||
|
||||
@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"}}
|
||||
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)
|
||||
registration.refresh_from_db()
|
||||
if registration.final_price is None:
|
||||
self.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 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)
|
||||
|
||||
# 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())
|
||||
0
apps/payments/tests/unit/__init__.py
Normal file
0
apps/payments/tests/unit/__init__.py
Normal file
194
apps/payments/tests/unit/test_payments.py
Normal file
194
apps/payments/tests/unit/test_payments.py
Normal file
@@ -0,0 +1,194 @@
|
||||
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
|
||||
from apps.payments.resources import DiscountResource, PaymentResource
|
||||
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.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.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.update(overrides)
|
||||
return DiscountCode.objects.create(**defaults)
|
||||
|
||||
|
||||
class DiscountCodeModelTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
self.event = self._create_event()
|
||||
self.user = self._create_user(is_email_verified=True)
|
||||
|
||||
def test_zero_price_returns_zero_discount(self):
|
||||
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))
|
||||
|
||||
def test_inactive_raises_error(self):
|
||||
code = self._discount_code(is_active=False)
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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)
|
||||
|
||||
|
||||
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin):
|
||||
def setUp(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.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,
|
||||
)
|
||||
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)
|
||||
|
||||
def test_discount_resource_expands_events(self):
|
||||
resource = DiscountResource()
|
||||
widget = resource.fields["event"].widget
|
||||
self.assertEqual(widget.separator, "||")
|
||||
|
||||
|
||||
class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
|
||||
|
||||
def test_deactivate_codes_action(self):
|
||||
code = self._discount_code()
|
||||
queryset = DiscountCode.objects.filter(pk=code.pk)
|
||||
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
|
||||
self.admin.deactivate_codes(request, queryset)
|
||||
code.refresh_from_db()
|
||||
self.assertFalse(code.is_active)
|
||||
Reference in New Issue
Block a user