393 lines
16 KiB
Python
393 lines
16 KiB
Python
from datetime import timedelta
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.test import TestCase, override_settings
|
|
from django.utils import timezone
|
|
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 Order, OrderStatus, PromoCode, Referral
|
|
|
|
User = get_user_model()
|
|
|
|
CONSOLE = "apps.payments.services.gateway.ConsolePaymentBackend"
|
|
|
|
|
|
def _teacher(phone="09120000001"):
|
|
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
|
|
TeacherProfile.objects.create(user=u, is_approved=True)
|
|
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):
|
|
c = Course.objects.create(
|
|
instructor=instructor, title="C", tagline="T", description="D",
|
|
level="beginner", language="fa", price_toman=price,
|
|
)
|
|
c.publish()
|
|
sec = Section.objects.create(course=c, title="S")
|
|
Lesson.objects.create(section=sec, title="L", kind=LessonKind.VIDEO, video_url="https://e.com/x", status=LessonStatus.PUBLISHED)
|
|
return c
|
|
|
|
|
|
# ==================================================================== referral code
|
|
|
|
class ReferralCodeAutoTests(TestCase):
|
|
def test_referral_code_auto_generated_on_save(self):
|
|
u = User.objects.create_user(phone_number="09120000010", full_name="X", role=Role.STUDENT)
|
|
self.assertEqual(len(u.referral_code), 6)
|
|
u2 = User.objects.create_user(phone_number="09120000011", full_name="Y", role=Role.STUDENT)
|
|
self.assertNotEqual(u.referral_code, u2.referral_code)
|
|
|
|
|
|
# ==================================================================== promo codes
|
|
|
|
@override_settings(PAYMENT_BACKEND=CONSOLE)
|
|
class PromoCodeCheckoutTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.t = _teacher()
|
|
self.s = _student()
|
|
self.course = _paid_course(self.t, price=1_000_000)
|
|
|
|
def test_percent_promo_applied(self):
|
|
PromoCode.objects.create(code="WELCOME10", discount_percent=10, is_active=True)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "welcome10"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 201, r.content)
|
|
order = Order.objects.get(pk=r.json()["order_id"])
|
|
self.assertEqual(order.base_amount_toman, 1_000_000)
|
|
self.assertEqual(order.discount_amount_toman, 100_000)
|
|
self.assertEqual(order.amount_toman, 900_000)
|
|
self.assertEqual(order.promo_code.code, "WELCOME10")
|
|
|
|
def test_fixed_promo_applied(self):
|
|
PromoCode.objects.create(code="MINUS200K", discount_amount_toman=200_000, is_active=True)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "MINUS200K"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 201)
|
|
order = Order.objects.get(pk=r.json()["order_id"])
|
|
self.assertEqual(order.amount_toman, 800_000)
|
|
|
|
def test_invalid_promo_400(self):
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "NOTREAL"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_expired_promo_400(self):
|
|
PromoCode.objects.create(
|
|
code="OLD", discount_percent=10,
|
|
valid_until=timezone.now() - timedelta(days=1),
|
|
)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "OLD"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_max_uses_exhausted(self):
|
|
PromoCode.objects.create(code="ONCE", discount_percent=10, max_uses=1, used_count=1)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "ONCE"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_promo_scoped_to_other_course_400(self):
|
|
other = _paid_course(self.t, price=500_000)
|
|
other.slug = "other"; other.save()
|
|
PromoCode.objects.create(code="OC10", discount_percent=10, course=other)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "OC10"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_promo_used_count_bumped_on_paid(self):
|
|
pc = PromoCode.objects.create(code="W10", discount_percent=10)
|
|
self.client.force_authenticate(self.s)
|
|
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/", {"promo_code": "W10"}, format="json").json()
|
|
self.client.get(
|
|
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
|
|
)
|
|
pc.refresh_from_db()
|
|
self.assertEqual(pc.used_count, 1)
|
|
|
|
def test_full_discount_skips_gateway(self):
|
|
PromoCode.objects.create(code="FREE100", discount_percent=100)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "FREE100"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 201)
|
|
body = r.json()
|
|
self.assertEqual(body["amount_toman"], 0)
|
|
self.assertEqual(body["gateway"], "free")
|
|
order = Order.objects.get(pk=body["order_id"])
|
|
self.assertEqual(order.status, OrderStatus.PAID)
|
|
self.assertTrue(
|
|
Enrollment.objects.filter(student=self.s, course=self.course).exists()
|
|
)
|
|
|
|
|
|
# ==================================================================== referral codes
|
|
|
|
@override_settings(PAYMENT_BACKEND=CONSOLE, REFERRAL_BUYER_DISCOUNT_PERCENT=10, REFERRAL_REFERRER_CREDIT_PERCENT=10)
|
|
class ReferralCheckoutTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.t = _teacher()
|
|
self.s = _student()
|
|
self.referrer = _student(phone="09120000050")
|
|
self.course = _paid_course(self.t, price=1_000_000)
|
|
|
|
def test_referral_applies_buyer_discount(self):
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"referral_code": self.referrer.referral_code}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 201, r.content)
|
|
order = Order.objects.get(pk=r.json()["order_id"])
|
|
self.assertEqual(order.discount_amount_toman, 100_000)
|
|
self.assertEqual(order.amount_toman, 900_000)
|
|
self.assertEqual(order.referrer_id, self.referrer.id)
|
|
|
|
def test_self_referral_rejected(self):
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"referral_code": self.s.referral_code}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_promo_and_referral_together_rejected(self):
|
|
PromoCode.objects.create(code="X", discount_percent=10)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"promo_code": "X", "referral_code": self.referrer.referral_code}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_referral_row_created_on_paid(self):
|
|
self.client.force_authenticate(self.s)
|
|
co = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/",
|
|
{"referral_code": self.referrer.referral_code}, format="json",
|
|
).json()
|
|
self.client.get(
|
|
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
|
|
)
|
|
ref = Referral.objects.get(order_id=co["order_id"])
|
|
self.assertEqual(ref.referrer_id, self.referrer.id)
|
|
self.assertEqual(ref.buyer_id, self.s.id)
|
|
self.assertEqual(ref.buyer_discount_toman, 100_000)
|
|
# 10% of 900_000 = 90_000
|
|
self.assertEqual(ref.referrer_credit_toman, 90_000)
|
|
|
|
|
|
# ==================================================================== preview
|
|
|
|
@override_settings(PAYMENT_BACKEND=CONSOLE)
|
|
class CheckoutPreviewTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.t = _teacher()
|
|
self.s = _student()
|
|
self.course = _paid_course(self.t, price=1_000_000)
|
|
|
|
def test_preview_with_promo_returns_discount(self):
|
|
PromoCode.objects.create(code="W10", discount_percent=10)
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/preview/",
|
|
{"promo_code": "W10"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertEqual(body["base_amount_toman"], 1_000_000)
|
|
self.assertEqual(body["discount_amount_toman"], 100_000)
|
|
self.assertEqual(body["final_amount_toman"], 900_000)
|
|
self.assertEqual(body["promo_code_applied"], "W10")
|
|
# Preview must NOT have created an Order
|
|
self.assertFalse(Order.objects.exists())
|
|
|
|
def test_preview_with_invalid_promo_400(self):
|
|
self.client.force_authenticate(self.s)
|
|
r = self.client.post(
|
|
f"/api/courses/{self.course.slug}/checkout/preview/",
|
|
{"promo_code": "BAD"}, format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
|
|
# ==================================================================== discount countdown
|
|
|
|
class DiscountCountdownTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.t = _teacher()
|
|
|
|
def test_discount_active_when_in_window(self):
|
|
c = Course.objects.create(
|
|
instructor=self.t, title="C", tagline="", description="",
|
|
level="beginner", language="fa",
|
|
price_toman=80, original_price_toman=100,
|
|
discount_starts_at=timezone.now() - timedelta(hours=1),
|
|
discount_ends_at=timezone.now() + timedelta(hours=1),
|
|
)
|
|
c.publish()
|
|
r = self.client.get(f"/api/courses/{c.slug}/")
|
|
body = r.json()
|
|
self.assertTrue(body["discount_active"])
|
|
self.assertIsNotNone(body["discount_ends_at"])
|
|
|
|
def test_discount_inactive_after_window(self):
|
|
c = Course.objects.create(
|
|
instructor=self.t, title="C2", tagline="", description="",
|
|
level="beginner", language="fa",
|
|
price_toman=80, original_price_toman=100,
|
|
discount_ends_at=timezone.now() - timedelta(hours=1),
|
|
)
|
|
c.publish()
|
|
r = self.client.get(f"/api/courses/{c.slug}/")
|
|
self.assertFalse(r.json()["discount_active"])
|
|
|
|
def test_discount_inactive_when_no_original_price(self):
|
|
c = Course.objects.create(
|
|
instructor=self.t, title="C3", tagline="", description="",
|
|
level="beginner", language="fa",
|
|
price_toman=100,
|
|
)
|
|
c.publish()
|
|
r = self.client.get(f"/api/courses/{c.slug}/")
|
|
self.assertFalse(r.json()["discount_active"])
|
|
|
|
|
|
# ==================================================================== featured instructors filter
|
|
|
|
class FeaturedInstructorsTests(TestCase):
|
|
def test_is_featured_filter(self):
|
|
client = APIClient()
|
|
a = _teacher(phone="09120000001")
|
|
b = _teacher(phone="09120000002")
|
|
a.teacher_profile.is_featured = True
|
|
a.teacher_profile.save()
|
|
r = client.get("/api/instructors/?is_featured=true")
|
|
ids = [row["id"] for row in r.json()]
|
|
self.assertIn(a.id, ids)
|
|
self.assertNotIn(b.id, ids)
|
|
|
|
|
|
# ==================================================================== global search
|
|
|
|
class GlobalSearchTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.t = _teacher()
|
|
Course.objects.create(
|
|
instructor=self.t, title="Python Basics", tagline="", description="",
|
|
level="beginner", language="fa", price_toman=None,
|
|
).publish()
|
|
Course.objects.create(
|
|
instructor=self.t, title="Persian Typography", tagline="", description="",
|
|
level="beginner", language="fa",
|
|
).publish()
|
|
|
|
def test_missing_q_400(self):
|
|
r = self.client.get("/api/search/")
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_search_courses_default(self):
|
|
r = self.client.get("/api/search/?q=Python")
|
|
self.assertEqual(r.status_code, 200)
|
|
body = r.json()
|
|
self.assertIn("courses", body)
|
|
titles = [c["title"] for c in body["courses"]]
|
|
self.assertIn("Python Basics", titles)
|
|
|
|
def test_search_instructors_only(self):
|
|
r = self.client.get("/api/search/?q=Teacher&types=instructors")
|
|
body = r.json()
|
|
self.assertNotIn("courses", body)
|
|
self.assertIn("instructors", body)
|
|
|
|
|
|
# ==================================================================== IDPay backend
|
|
|
|
class IDPayBackendUnitTests(TestCase):
|
|
"""Unit-test IDPay's start/verify with mocked HTTP — we can't reach api.idpay.ir from dev."""
|
|
|
|
@override_settings(IDPAY_API_KEY="fake-key", IDPAY_SANDBOX=True)
|
|
def test_start_stores_authority_and_returns_link(self):
|
|
from unittest.mock import patch
|
|
|
|
from apps.payments.services.gateway import IDPayBackend
|
|
|
|
t = _teacher()
|
|
s = _student()
|
|
course = _paid_course(t, price=1000)
|
|
order = Order.objects.create(
|
|
student=s, course=course,
|
|
base_amount_toman=1000, amount_toman=1000,
|
|
gateway="idpay", status=OrderStatus.PENDING,
|
|
)
|
|
with patch("apps.payments.services.gateway.requests.post") as mock_post:
|
|
mock_post.return_value.status_code = 200
|
|
mock_post.return_value.json.return_value = {"id": "FAKE-AUTH", "link": "https://idpay.ir/p/ws/FAKE-AUTH"}
|
|
backend = IDPayBackend()
|
|
redirect = backend.start(order, "https://example/cb")
|
|
order.refresh_from_db()
|
|
self.assertEqual(order.authority, "FAKE-AUTH")
|
|
self.assertEqual(redirect, "https://idpay.ir/p/ws/FAKE-AUTH")
|
|
|
|
@override_settings(IDPAY_API_KEY="fake-key", IDPAY_SANDBOX=True)
|
|
def test_verify_success(self):
|
|
from unittest.mock import patch
|
|
|
|
from apps.payments.services.gateway import IDPayBackend
|
|
|
|
t = _teacher()
|
|
s = _student()
|
|
course = _paid_course(t, price=1000)
|
|
order = Order.objects.create(
|
|
student=s, course=course,
|
|
base_amount_toman=1000, amount_toman=1000,
|
|
gateway="idpay", status=OrderStatus.PENDING, authority="AUTH-1",
|
|
)
|
|
with patch("apps.payments.services.gateway.requests.post") as mock_post:
|
|
mock_post.return_value.status_code = 200
|
|
mock_post.return_value.json.return_value = {"status": 100, "track_id": "TRACK-1"}
|
|
backend = IDPayBackend()
|
|
ok, ref_id, err = backend.verify(order)
|
|
self.assertTrue(ok)
|
|
self.assertEqual(ref_id, "TRACK-1")
|
|
self.assertIsNone(err)
|
|
|
|
@override_settings(IDPAY_API_KEY="")
|
|
def test_init_without_key_fails(self):
|
|
from apps.payments.services.gateway import IDPayBackend, PaymentGatewayError
|
|
|
|
with self.assertRaises(PaymentGatewayError):
|
|
IDPayBackend()
|