Ilo/apps/payments/tests.py
2026-05-02 20:01:30 +03:30

286 lines
11 KiB
Python

from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework.test import APIClient
from apps.accounts.models import Role, TeacherProfile
from apps.courses.models import (
Course,
Lesson,
LessonKind,
LessonStatus,
Section,
)
from apps.enrollments.models import Enrollment
from apps.payments.models import Gateway, Order, OrderStatus
User = get_user_model()
CONSOLE_PAYMENT = "apps.payments.services.gateway.ConsolePaymentBackend"
# --------------------------------------------------------------------------- helpers
def _teacher(phone="09120000001", approved=True):
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=approved)
return u
def _student(phone="09120000099"):
return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT)
def _paid_course(instructor, *, price=1_000_000, n_lessons=2, published=True):
c = Course.objects.create(
instructor=instructor, title="Pay Course", tagline="T", description="D",
level="beginner", language="fa", price_toman=price,
)
if published:
c.publish()
sec = Section.objects.create(course=c, title="S1", order=0)
for i in range(n_lessons):
Lesson.objects.create(
section=sec, title=f"L{i+1}", kind=LessonKind.VIDEO,
video_url=f"https://e.com/{i}", status=LessonStatus.PUBLISHED, order=i,
)
return c
def _free_course(instructor, *, published=True):
c = Course.objects.create(
instructor=instructor, title="Free", tagline="T", description="D",
level="beginner", language="fa", price_toman=None,
)
if published:
c.publish()
return c
# --------------------------------------------------------------------------- order math
class OrderMathTests(TestCase):
@override_settings(ILO_COMMISSION_PERCENT=15, PAYMENT_BACKEND=CONSOLE_PAYMENT)
def test_commission_split_at_15_percent(self):
from apps.payments.services.orders import start_checkout
t = _teacher()
s = _student()
course = _paid_course(t, price=1_000_000)
order, redirect_url = start_checkout(s, course)
self.assertEqual(order.amount_toman, 1_000_000)
self.assertEqual(order.ilo_fee_toman, 150_000)
self.assertEqual(order.instructor_share_toman, 850_000)
self.assertEqual(order.commission_percent_snapshot, 15)
self.assertEqual(order.gateway, Gateway.CONSOLE)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertTrue(order.authority.startswith("CONSOLE-"))
self.assertIn("Authority=CONSOLE-", redirect_url)
self.assertIn("Status=OK", redirect_url)
@override_settings(ILO_COMMISSION_PERCENT=20, PAYMENT_BACKEND=CONSOLE_PAYMENT)
def test_commission_uses_setting(self):
from apps.payments.services.orders import start_checkout
t = _teacher()
s = _student()
course = _paid_course(t, price=500_000)
order, _ = start_checkout(s, course)
self.assertEqual(order.ilo_fee_toman, 100_000)
self.assertEqual(order.instructor_share_toman, 400_000)
# --------------------------------------------------------------------------- checkout endpoint
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT)
class CheckoutAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.paid = _paid_course(self.t, price=2_000_000)
self.free = _free_course(self.t)
def test_anon_blocked(self):
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 401)
def test_teacher_blocked(self):
self.client.force_authenticate(self.t)
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 403)
def test_student_checkout_returns_redirect(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 201, r.content)
body = r.json()
self.assertEqual(body["amount_toman"], 2_000_000)
self.assertEqual(body["gateway"], "console")
self.assertIn("Authority=CONSOLE-", body["redirect_url"])
self.assertEqual(Order.objects.filter(student=self.s).count(), 1)
def test_checkout_for_free_course_400(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.free.slug}/checkout/")
self.assertEqual(r.status_code, 400)
def test_checkout_when_already_enrolled_400(self):
Enrollment.objects.create(student=self.s, course=self.paid)
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 400)
def test_enroll_endpoint_returns_402_for_paid_course(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.paid.slug}/enroll/")
self.assertEqual(r.status_code, 402)
body = r.json()
self.assertIn("checkout_url", body)
self.assertEqual(body["price_toman"], 2_000_000)
# --------------------------------------------------------------------------- callback
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT, PAYMENT_SUCCESS_REDIRECT_URL="/p/ok", PAYMENT_FAILURE_REDIRECT_URL="/p/fail")
class CallbackAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=1_000_000)
def _open_pending_order(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.course.slug}/checkout/")
order_id = r.json()["order_id"]
# The console redirect URL has Authority=CONSOLE-<id>&Status=OK&order_id=<id>
return Order.objects.get(pk=order_id)
def test_full_flow_success(self):
order = self._open_pending_order()
r = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}"
)
self.assertEqual(r.status_code, 302)
self.assertIn("/p/ok", r["Location"])
order.refresh_from_db()
self.assertEqual(order.status, OrderStatus.PAID)
self.assertTrue(
Enrollment.objects.filter(student=self.s, course=self.course).exists()
)
def test_callback_idempotent(self):
order = self._open_pending_order()
r1 = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}"
)
r2 = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}"
)
self.assertEqual(r1.status_code, 302)
self.assertEqual(r2.status_code, 302)
# Still exactly one enrollment
self.assertEqual(
Enrollment.objects.filter(student=self.s, course=self.course).count(), 1
)
def test_callback_user_cancel_marks_failed(self):
order = self._open_pending_order()
r = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=NOK&Authority={order.authority}"
)
self.assertEqual(r.status_code, 302)
self.assertIn("/p/fail", r["Location"])
order.refresh_from_db()
self.assertEqual(order.status, OrderStatus.FAILED)
self.assertFalse(
Enrollment.objects.filter(student=self.s, course=self.course).exists()
)
def test_callback_unknown_order(self):
r = self.client.get(
"/api/payments/callback/console/?order_id=999999&Status=OK&Authority=X"
)
self.assertEqual(r.status_code, 302)
self.assertIn("/p/fail", r["Location"])
# --------------------------------------------------------------------------- order lists
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT)
class OrderListAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=1_500_000)
def test_my_orders_lists_own(self):
# complete a full flow first
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
r = self.client.get("/api/me/orders/")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["status"], "paid")
self.assertEqual(body[0]["amount_toman"], 1_500_000)
self.assertEqual(body[0]["course_slug"], self.course.slug)
def test_instructor_sales(self):
# student buys
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
# teacher sees the sale
self.client.force_authenticate(self.t)
r = self.client.get("/api/instructor/orders/?status=paid")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["amount_toman"], 1_500_000)
self.assertEqual(body[0]["ilo_fee_toman"], 225_000) # 15%
self.assertEqual(body[0]["instructor_share_toman"], 1_275_000)
self.assertEqual(body[0]["student_id"], self.s.id)
def test_student_blocked_from_instructor_sales(self):
self.client.force_authenticate(self.s)
r = self.client.get("/api/instructor/orders/")
self.assertEqual(r.status_code, 403)
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT)
class OrderDetailAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=500_000)
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json()
self.order_id = co["order_id"]
self.client.get(
f"/api/payments/callback/console/?order_id={self.order_id}&Status=OK&Authority=CONSOLE-{self.order_id}"
)
def test_get_my_order_detail(self):
r = self.client.get(f"/api/me/orders/{self.order_id}/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(body["id"], self.order_id)
self.assertEqual(body["status"], "paid")
self.assertEqual(body["amount_toman"], 500_000)
self.assertTrue(body["ref_id"])
def test_cannot_get_others_order(self):
other = _student(phone="09120000022")
self.client.force_authenticate(other)
r = self.client.get(f"/api/me/orders/{self.order_id}/")
self.assertEqual(r.status_code, 404)