This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+192 -3
View File
@@ -1,13 +1,20 @@
from unittest.mock import Mock, patch
import jwt
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.account.models import User
from apps.billing.models import BazaarPurchase, BazaarSettings, GemPack, GemTransaction
from apps.billing.api.services import FeatureAccessService
from apps.billing.models import (
BazaarPurchase,
BazaarSettings,
FeaturePlan,
GemPack,
GemTransaction,
)
from apps.billing.providers.bazaar import (
BazaarAuthError,
build_authorize_url,
@@ -90,7 +97,9 @@ class BazaarOAuthTests(TestCase):
def test_build_authorize_url_shape(self):
url = build_authorize_url("https://example.com/callback/")
self.assertIn("client_id=client-123", url)
self.assertIn("redirect_uri=https://example.com/callback/", url)
# urlencode مقدار را percent-encode می‌کند — همین درست است، چون
# redirect_uri باید به‌شکلِ امنِ query-string فرستاده شود.
self.assertIn("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback%2F", url)
self.assertIn("response_type=code", url)
@patch("apps.billing.providers.bazaar.requests.post")
@@ -299,3 +308,183 @@ class PurchaseFlowIntegrationTests(TestCase):
second_id = self._create_purchase(token="same-token")
self.assertEqual(first_id, second_id)
self.assertEqual(BazaarPurchase.objects.filter(purchase_token="same-token").count(), 1)
class FreeFeatureAccessTests(TestCase):
"""قوانین انتشار کافه‌بازار: بخشی از امکانات باید بدون خرید در دسترس
باشد. این تست‌ها تضمین می‌کنند ابزارهای رایگان همیشه باز بمانند — حتی
بعد از پایانِ دوره‌ی آزمایشی."""
def setUp(self):
self.user = User.objects.create_user(phone="+989127777777", password="pass")
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_seed_creates_features_and_gem_packs(self):
# ریشه‌ی باگی که کافه‌بازار گزارش کرد: نبودِ داده‌ی پایه یعنی هر
# ابزار «فعال نیست» نشان داده می‌شود.
self.assertGreater(FeaturePlan.objects.count(), 0)
self.assertGreater(GemPack.objects.filter(is_active=True).count(), 0)
def test_at_least_one_feature_is_free(self):
self.assertTrue(
FeaturePlan.objects.filter(is_free=True).exists(),
"حداقل یک ابزار باید رایگان باشد وگرنه انتشار رد می‌شود.",
)
def test_free_feature_is_active_without_trial_or_gems(self):
self.user.trial_expires_at = timezone.now() - timezone.timedelta(days=1)
self.user.gem_balance = 0
self.user.save()
self.assertFalse(self.user.is_in_trial)
for feature in FeaturePlan.objects.filter(is_free=True):
self.assertTrue(
FeatureAccessService.is_active(self.user, feature.key),
f"ابزار رایگان {feature.key} باید باز باشد.",
)
def test_paid_feature_is_locked_after_trial_ends(self):
self.user.trial_expires_at = timezone.now() - timezone.timedelta(days=1)
self.user.save()
paid = FeaturePlan.objects.filter(is_free=False).first()
self.assertIsNotNone(paid)
self.assertFalse(FeatureAccessService.is_active(self.user, paid.key))
def test_paid_feature_is_open_during_trial(self):
self.user.trial_expires_at = timezone.now() + timezone.timedelta(days=3)
self.user.save()
paid = FeaturePlan.objects.filter(is_free=False).first()
self.assertTrue(FeatureAccessService.is_active(self.user, paid.key))
def test_features_endpoint_reports_free_flag(self):
response = self.client.get(reverse("billing:feature-list"))
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 0)
free = [f for f in response.data if f["is_free"]]
self.assertGreater(len(free), 0)
for f in free:
self.assertTrue(f["is_active"])
def test_free_features_have_no_price_tiers(self):
# ابزارِ رایگان نباید در فروشگاه قیمت نشان دهد.
for feature in FeaturePlan.objects.filter(is_free=True):
self.assertEqual(feature.tiers.count(), 0)
def test_expected_free_and_paid_split(self):
"""ترکیبِ تصمیم‌گرفته‌شده‌ی محصول — اگر کسی سهوا عوضش کند، همین‌جا
گیر می‌افتد (چون رایگان‌بودنِ این سه، شرطِ انتشار در کافه‌بازار است)."""
free = set(FeaturePlan.objects.filter(is_free=True).values_list("key", flat=True))
paid = set(FeaturePlan.objects.filter(is_free=False).values_list("key", flat=True))
self.assertEqual(free, {"customers", "sms_bank", "invoice"})
self.assertEqual(paid, {"ledger", "inventory", "orders"})
def test_paid_features_have_price_tiers(self):
for feature in FeaturePlan.objects.filter(is_free=False, is_purchasable=True):
self.assertGreater(
feature.tiers.count(), 0, f"{feature.key} قیمت ندارد."
)
class GuestFeatureCatalogTests(TestCase):
"""کاربرِ مهمان (بدون حساب) باید بتواند کاتالوگ ابزارها را ببیند و
بفهمد کدام رایگان است — وگرنه پیش از ورود همه‌چیز قفل به‌نظر می‌رسد."""
def setUp(self):
self.client = APIClient() # عمدا بدون احراز هویت
def test_anonymous_can_list_features(self):
response = self.client.get(reverse("billing:feature-list"))
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 0)
def test_free_features_report_active_for_anonymous(self):
response = self.client.get(reverse("billing:feature-list"))
by_key = {f["key"]: f for f in response.data}
for key in ["customers", "sms_bank", "invoice"]:
self.assertTrue(by_key[key]["is_free"], key)
self.assertTrue(by_key[key]["is_active"], key)
def test_paid_features_are_inactive_for_anonymous(self):
response = self.client.get(reverse("billing:feature-list"))
by_key = {f["key"]: f for f in response.data}
for key in ["ledger", "inventory", "orders"]:
self.assertFalse(by_key[key]["is_active"], key)
def test_anonymous_gets_no_expiry(self):
response = self.client.get(reverse("billing:feature-list"))
for f in response.data:
self.assertIsNone(f["expires_at"])
def test_wallet_still_requires_auth(self):
# کاتالوگ عمومی شد، ولی داده‌ی شخصی نه.
response = self.client.get(reverse("billing:wallet"))
self.assertIn(response.status_code, (401, 403))
class GemGiftAdminTests(TestCase):
"""هدیه‌ی جم از پنل ادمین — موجودی و تاریخچه باید با هم جلو بروند."""
def setUp(self):
self.staff = User.objects.create_superuser(phone="09120000001", password="x")
self.client = Client()
self.client.force_login(self.staff)
self.url = reverse("admin:billing_gemtransaction_gift")
def test_phone_normalization_accepts_the_shapes_admins_actually_paste(self):
from apps.billing.forms import normalize_iran_phone
for raw in ["09121234567", "+989121234567", "00989121234567",
"9121234567", "۰۹۱۲۱۲۳۴۵۶۷", "0912 123 4567"]:
self.assertEqual(normalize_iran_phone(raw), "09121234567", raw)
def test_gift_adds_gems_and_records_history(self):
target = User.objects.create(phone="09121112233")
response = self.client.post(
self.url,
{"phones": "09121112233", "amount": 250, "note": "جبران خطا"},
)
self.assertEqual(response.status_code, 302)
target.refresh_from_db()
self.assertEqual(target.gem_balance, 250)
tx = GemTransaction.objects.get(user=target)
self.assertEqual(tx.amount, 250)
self.assertEqual(tx.reason, GemTransaction.Reason.ADMIN_ADJUSTMENT)
self.assertEqual(tx.note, "جبران خطا")
def test_gift_handles_several_numbers_in_one_go(self):
a = User.objects.create(phone="09121112233")
b = User.objects.create(phone="09124445566")
self.client.post(
self.url,
{"phones": "09121112233\n+989124445566", "amount": 100},
)
a.refresh_from_db()
b.refresh_from_db()
self.assertEqual((a.gem_balance, b.gem_balance), (100, 100))
def test_unknown_number_is_skipped_but_the_rest_still_get_gems(self):
known = User.objects.create(phone="09121112233")
self.client.post(
self.url,
{"phones": "09121112233\n09129998877", "amount": 50},
)
known.refresh_from_db()
self.assertEqual(known.gem_balance, 50)
self.assertFalse(User.objects.filter(phone="09129998877").exists())
def test_create_missing_makes_the_user_so_the_gift_waits_for_them(self):
self.client.post(
self.url,
{"phones": "09129998877", "amount": 75, "create_missing": "on"},
)
created = User.objects.get(phone="09129998877")
self.assertEqual(created.gem_balance, 75)
def test_invalid_number_rejects_the_whole_form_rather_than_half_applying(self):
User.objects.create(phone="09121112233")
response = self.client.post(
self.url, {"phones": "09121112233\n12345", "amount": 10}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(GemTransaction.objects.count(), 0)