Files
dealerhub/backend/tests/test_billing.py
T

374 lines
12 KiB
Python

"""批次 C1 · 套餐/试用/配额系统测试。
覆盖:预置套餐、订阅开通与幂等、试用到期降级、配额拦截(商品数/单据量/AI 次数)、
功能开关(批次管理/总账/订货商城)、升级解除限制、租户隔离、API 全链路。
"""
import pytest
from datetime import date, timedelta
from decimal import Decimal
from django.utils import timezone
from model_bakery import baker
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product
from apps.inventory.models import Warehouse
from apps.partner.models import Customer
from apps.ai import usage as ai_usage
from apps.billing.models import Plan, Subscription, subscribe, upgrade, get_subscription, seed_plans
from apps.billing import quota as billing_quota
from apps.billing import tasks as billing_tasks
@pytest.fixture
def auth_client(db, user, tenant):
c = APIClient()
refresh = RefreshToken.for_user(user)
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return c
@pytest.fixture
def plans(db):
seed_plans()
return {p.code: p for p in Plan.objects.all()}
@pytest.fixture
def warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店")
@pytest.fixture
def product(db, tenant):
return baker.make(Product, tenant=tenant, code="P001", name="可乐",
sale_price=Decimal("10"))
# ---------- 套餐定义 ----------
def test_seed_plans_idempotent(db, plans):
assert Plan.objects.count() == 3
seed_plans()
assert Plan.objects.count() == 3
free = Plan.objects.get(code="free")
assert free.price_monthly == 0
assert free.limits["products"] == 100
assert Plan.objects.get(code="basic").price_monthly == 998
assert Plan.objects.get(code="pro").price_monthly == 4800
def test_plan_limits_pricing_consistency(db, plans):
"""价格页与配额同源:每个套餐的 limits 都含全部键(一处定义两处使用)。"""
keys = {"users", "products", "bills_monthly", "ai_parse_order", "ai_ask",
"batch_managed", "finance_ledger", "print_templates", "storefront"}
for plan in Plan.objects.all():
assert keys <= set(plan.limits.keys()), f"{plan.code} 缺键"
# ---------- 订阅 ----------
def test_subscribe_creates_trial(db, tenant, plans):
sub = subscribe(tenant)
assert sub.status == "trial"
assert sub.plan.code == "free"
assert sub.trial_ends_at > timezone.now()
# 幂等
again = subscribe(tenant)
assert again.id == sub.id
def test_get_subscription_autocreates(db, tenant, plans):
assert Subscription.objects.filter(tenant=tenant).count() == 0
sub = get_subscription(tenant)
assert sub is not None
assert Subscription.objects.filter(tenant=tenant).count() == 1
def test_trial_expired_falls_back_to_free_limits(db, tenant, plans):
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="pro")
sub.trial_ends_at = timezone.now() - timedelta(hours=1)
sub.save()
assert sub.is_trial_expired is True
limits = sub.effective_limits()
assert limits["products"] == 100 # 回落 free
assert limits["storefront"] is False
def test_upgrade_sets_active_and_extends(db, tenant, plans):
sub = subscribe(tenant)
up = upgrade(tenant, "pro", months=2)
assert up.plan.code == "pro"
assert up.status == "active"
assert up.trial_ends_at is None
assert up.period_end > date.today() + timedelta(days=50)
def test_upgrade_unknown_plan_raises(db, tenant, plans):
with pytest.raises(ValueError):
upgrade(tenant, "nope")
# ---------- 配额 ----------
def test_products_quota_blocks(db, tenant, plans):
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="free")
sub.status = "active"
sub.save()
# free 限 100 个商品
for i in range(100):
baker.make(Product, tenant=tenant, code=f"P{i:03d}", name=f"商品{i}")
with pytest.raises(billing_quota.QuotaExceeded) as ei:
billing_quota.check_and_count(tenant, "products", delta=1)
assert ei.value.kind == "products"
assert ei.value.limit == 100
assert ei.value.as_dict()["upgrade_url"] == "/#/pricing"
def test_products_quota_allows_under_limit(db, tenant, plans):
subscribe(tenant)
baker.make(Product, tenant=tenant, code="P1", name="商品1")
out = billing_quota.check_and_count(tenant, "products", delta=1)
assert out["used"] == 1
assert out["remaining"] == 99
def test_unlimited_when_limit_zero(db, tenant, plans):
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="pro")
sub.status = "active"
sub.save()
out = billing_quota.check_and_count(tenant, "bills_monthly", delta=1)
assert out["limit"] == 0
assert out["remaining"] is None
def test_bills_monthly_counts_sales_and_purchase(db, tenant, plans, warehouse, customer, product):
from apps.sales import services as sales_services
subscribe(tenant)
bill = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 1, "unit_price": 10}],
)
out = billing_quota.check_and_count(tenant, "bills_monthly", delta=0)
assert out["used"] >= 1
def test_feature_flag_blocks_when_disabled(db, tenant, plans):
"""free 版无 storefront(订货商城)→ 拦截。"""
subscribe(tenant)
with pytest.raises(billing_quota.QuotaExceeded) as ei:
billing_quota.check_and_count(tenant, "storefront")
assert "订货商城" in ei.value.as_dict()["detail"]
def test_feature_flag_allows_on_pro(db, tenant, plans):
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="pro")
sub.status = "active"
sub.save()
out = billing_quota.check_and_count(tenant, "storefront")
assert out["enabled"] is True
def test_ai_quota_delegates_to_ai_usage(db, tenant, plans, settings):
"""ai_* 走 apps.ai.usage 单一实现;billing 的 limits 是配额来源。"""
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="free")
sub.status = "active"
sub.save()
assert billing_quota.quota_for(tenant, "ai_parse_order") == 10
for _ in range(10):
ai_usage.record_usage(tenant, ai_usage.KIND_PARSE_ORDER)
with pytest.raises(billing_quota.QuotaExceeded):
billing_quota.check_and_count(tenant, "ai_parse_order", delta=1)
def test_unknown_kind_raises_valueerror(db, tenant, plans):
subscribe(tenant)
with pytest.raises(ValueError):
billing_quota.check_and_count(tenant, "nonexistent")
# ---------- 用量快照 + 到期处理 ----------
def test_usage_snapshot_structure(db, tenant, plans, product):
subscribe(tenant)
snap = billing_quota.usage_snapshot(tenant)
assert snap["plan"]["code"] == "free"
kinds = {i["kind"] for i in snap["items"]}
assert {"users", "products", "bills_monthly",
"ai_parse_order", "ai_ask"} <= kinds
prod_item = next(i for i in snap["items"] if i["kind"] == "products")
assert prod_item["used"] == 1
assert prod_item["limit"] == 100
assert prod_item["percent"] == 1.0
assert "features" in snap
def test_expire_trials_downgrades_and_notifies(db, tenant, plans):
from apps.notify.models import Notification
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="pro")
sub.trial_ends_at = timezone.now() - timedelta(minutes=1)
sub.save()
out = billing_tasks.expire_trials()
assert out["expired_count"] == 1
sub.refresh_from_db()
assert sub.status == "expired"
assert sub.plan.code == "free"
assert Notification.objects.filter(
tenant=tenant, extra_data__kind="trial_expired"
).count() == 1
def test_expire_trials_ignores_active_trial(db, tenant, plans):
subscribe(tenant) # 未到期
out = billing_tasks.expire_trials()
assert out["expired_count"] == 0
def test_paid_subscription_expiry(db, tenant, plans):
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="basic")
sub.status = "active"
sub.period_end = date.today() - timedelta(days=1)
sub.save()
out = billing_tasks.expire_trials()
assert out["expired_count"] == 1
sub.refresh_from_db()
assert sub.status == "expired"
# ---------- API ----------
def test_plans_api(db, client):
resp = client.get("/api/v1/billing/plans/")
assert resp.status_code == 200, resp.content
body = resp.json()
assert len(body) == 3
codes = [p["code"] for p in body]
assert codes == ["free", "basic", "pro"]
basic = next(p for p in body if p["code"] == "basic")
assert basic["price_monthly"] == "998.00"
assert basic["limits"]["products"] == 3000
def test_subscription_api(db, auth_client, tenant, plans):
resp = auth_client.get("/api/v1/billing/subscription/")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["plan"]["code"] == "free"
assert body["status"] == "trial"
def test_subscribe_api(db, auth_client, tenant, plans):
resp = auth_client.post("/api/v1/billing/subscribe/",
{"plan_code": "basic", "months": 1}, format="json")
assert resp.status_code == 200, resp.content
assert resp.json()["plan_code"] == "basic"
sub = Subscription.objects.get(tenant=tenant)
assert sub.status == "active"
def test_subscribe_api_bad_plan_400(db, auth_client, tenant, plans):
resp = auth_client.post("/api/v1/billing/subscribe/",
{"plan_code": "nope"}, format="json")
assert resp.status_code == 400
def test_quota_check_api_403_with_upgrade_hint(db, auth_client, tenant, plans):
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="free")
sub.status = "active"
sub.save()
for i in range(100):
baker.make(Product, tenant=tenant, code=f"Q{i:03d}", name=f"品{i}")
resp = auth_client.get("/api/v1/billing/quota/?kind=products")
assert resp.status_code == 403, resp.content
body = resp.json()
assert body["code"] == "quota_exceeded"
assert body["limit"] == 100
assert body["upgrade_url"] == "/#/pricing"
def test_create_product_blocked_by_quota(db, auth_client, tenant, plans):
"""真实拦截点:商品 ViewSet 的 acreate 被配额挡住。"""
sub = subscribe(tenant)
sub.plan = Plan.objects.get(code="free")
sub.status = "active"
sub.save()
for i in range(100):
baker.make(Product, tenant=tenant, code=f"R{i:03d}", name=f"货{i}")
resp = auth_client.post("/api/v1/catalog/products/",
{"code": "NEW01", "name": "新商品"}, format="json")
assert resp.status_code == 403, resp.content
assert resp.json()["code"] == "quota_exceeded"
def test_create_product_ok_under_quota(db, auth_client, tenant, plans):
subscribe(tenant)
resp = auth_client.post("/api/v1/catalog/products/",
{"code": "OK01", "name": "正常商品"}, format="json")
assert resp.status_code == 201, resp.content
def test_tenant_isolation_subscription(db, tenant, other_tenant, plans):
sub = subscribe(tenant)
other = subscribe(other_tenant)
upgrade(tenant, "pro")
sub.refresh_from_db()
other.refresh_from_db()
assert sub.plan.code == "pro"
assert other.plan.code == "free"
assert billing_quota.quota_for(tenant, "products") == 100000
assert billing_quota.quota_for(other_tenant, "products") == 100
# ---------- 定时任务接线(C1 收尾) ----------
def test_billing_expiry_cron_registered(db, settings):
"""套餐到期检查必须挂进 dramatiq crontab(否则试用永不降级)。"""
settings.TASK_BROKER = "stub"
import config.dramatiq as dramatiq_setup
assert hasattr(dramatiq_setup, "cron_daily_billing_expiry")
from apps.billing.tasks import expire_trials_and_notify
assert expire_trials_and_notify.actor_name == "expire_trials_and_notify"
def test_scheduler_tick_billing_expiry_runs(db, tenant, plans):
"""tick actor 可直接调用并返回降级结果。"""
from datetime import timedelta
from django.utils import timezone
import config.dramatiq as dramatiq_setup
sub = subscribe(tenant)
sub.trial_ends_at = timezone.now() - timedelta(minutes=1)
sub.save()
out = dramatiq_setup.scheduler_tick_billing_expiry.fn()
assert out["expired_count"] == 1