"""批次 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