286 lines
11 KiB
Python
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)
|