Files
xiaoxia-saas/tests/unit/test_points_routes.py
T
xiaoxia 41cd9cdc56
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m45s
CI/CD Pipeline / Integration Tests (push) Successful in 3m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m28s
CI/CD Pipeline / Validate - Style (push) Successful in 4m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Validate - Security (push) Successful in 8m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m24s
CI/CD Pipeline / Unit Tests (push) Successful in 10m40s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 5h13m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 5h13m27s
CI/CD Pipeline / PR Build Web Image (push) Failing after 5h24m10s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 5h23m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 5h23m18s
CI/CD Pipeline / Deploy Production (push) Failing after 5h12m32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 5h12m35s
CI/CD Pipeline / Build Production Web Image (push) Failing after 5h12m35s
CI/CD Pipeline / Canary Release to Production (push) Failing after 5h12m32s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 5h22m32s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 5h22m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 5h23m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 5h23m18s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 5h59m5s
fix(#1895): /subscription/plans 路由挂载位置修复(解决 404) (#1955)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-16 09:59:30 +08:00

220 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""积分/会员 API 路由对齐测试 — fix/1895-points-api-align
覆盖:
- P0-1: POST /points/recharge 返回 pay_params / points_amount / expire_at
- P0-2: POST /points/check 未知 scene_key 返回 400(非 500)
- P1-3: GET /points/rules 返回 description 字段
- P1-6: GET /subscription/plans 返回档位列表
- P1-7: multiplier 实际扣费一致(calculate_points_cost 统一应用)
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
def _make_cu(user_id="user-1", is_member=False, member_type=None):
cu = MagicMock()
cu.user.id = user_id
cu.user.is_member = is_member
cu.user.member_type = member_type
cu.user.member_expires_at = None
return cu
# ── P0-1: recharge response fields ────────────────────────────────────
class TestRechargeOrderResponse:
def test_recharge_returns_pay_params_points_amount_expire_at(self):
"""recharge 响应必须包含 pay_params / points_amount / expire_at。"""
from app.api.routes.points import create_recharge_order
from app.schemas.points import PointsRechargeRequest
svc = MagicMock()
svc.create_order.return_value = {
"id": "order-1",
"order_type": "points",
"product_code": "starter_pack",
"amount_cents": 990,
"status": "pending",
"created_at": datetime.now(UTC).isoformat(),
}
db = MagicMock()
cu = _make_cu()
body = PointsRechargeRequest(package_id="starter_pack")
before = datetime.now(UTC)
with patch("app.api.routes.points._get_service", return_value=svc):
resp = create_recharge_order(body=body, current_user=cu, db=db)
after = datetime.now(UTC) + timedelta(hours=48)
assert resp.points_amount == 100 # starter_pack 100 分
assert isinstance(resp.pay_params, dict)
assert resp.expire_at is not None
expire_dt = datetime.fromisoformat(resp.expire_at)
assert expire_dt >= before + timedelta(hours=47, minutes=55)
assert expire_dt <= after
def test_recharge_invalid_package_returns_400(self):
from app.api.routes.points import create_recharge_order
from app.schemas.points import PointsRechargeRequest
svc = MagicMock()
svc.create_order.side_effect = ValueError("invalid package")
db = MagicMock()
cu = _make_cu()
body = PointsRechargeRequest(package_id="nonexistent")
with pytest.raises(HTTPException) as exc, patch(
"app.api.routes.points._get_service", return_value=svc
):
create_recharge_order(body=body, current_user=cu, db=db)
assert exc.value.status_code == 400
# ── P0-2: check unknown scene → 400 ───────────────────────────────────
class TestCheckPointsUnknownScene:
def test_unknown_scene_returns_400_not_500(self):
"""未知 scene_key(如 ai_script)应返回 400 UNKNOWN_SCENE,而不是 500。"""
from app.api.routes.points import check_points
from app.schemas.points import PointsCheckRequest
db = MagicMock()
cu = _make_cu()
body = PointsCheckRequest(scene_key="ai_script", quantity=1)
with pytest.raises(HTTPException) as exc:
check_points(body=body, current_user=cu, db=db)
assert exc.value.status_code == 400
detail = exc.value.detail
assert detail["code"] == "UNKNOWN_SCENE"
assert "ai_script" in detail["message"]
assert "ai_voice" in detail["valid_scenes"]
assert "ai_title" in detail["valid_scenes"]
def test_known_scene_still_works(self):
"""合法 scene_key 正常返回,免费用户 ai_voice 1 分钟 = 2 积分。"""
from app.api.routes.points import check_points
from app.schemas.points import PointsCheckRequest
svc = MagicMock()
svc.check_daily_free_clip.return_value = False
svc.get_or_create_account.return_value = {"balance": 50}
db = MagicMock()
cu = _make_cu()
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
with patch("app.api.routes.points._get_service", return_value=svc):
resp = check_points(body=body, current_user=cu, db=db)
assert resp.required_points == 2 # ceil(1 * 1.15) = 2
assert resp.current_balance == 50
assert resp.allowed is True
# ── P1-3: rules include description ───────────────────────────────────
class TestPointsRulesDescription:
def test_rules_have_description_field(self):
from app.api.routes.points import get_rules
resp = get_rules(_current_user=_make_cu())
assert len(resp.rules) >= 9
for rule in resp.rules:
assert rule.description, f"{rule.scene_key} missing description"
assert isinstance(rule.description, str)
assert len(rule.description) > 0
def test_free_user_multiplier_returned(self):
from app.api.routes.points import get_rules
resp = get_rules(_current_user=_make_cu())
assert resp.free_user_multiplier == 1.15
# ── P1-6: GET /subscription/plans ─────────────────────────────────────
class TestSubscriptionPlans:
@staticmethod
def _import_plans_fn():
"""Import from the real file to avoid sys.modules shadowing by integration fixtures."""
import importlib.util
_route_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "apps", "api", "app", "api", "routes", "subscription.py",
)
_spec = importlib.util.spec_from_file_location(
"_real_subscription_routes", os.path.abspath(_route_path)
)
_mod = importlib.util.module_from_spec(_spec)
# inject settings before exec
import os as _os
_os.environ.setdefault("JWT_SECRET_KEY", "test-secret")
_spec.loader.exec_module(_mod)
return _mod.list_membership_plans
def test_plans_endpoint_returns_three_tiers(self):
import os # noqa: F401 (used by _import_plans_fn)
list_membership_plans = self._import_plans_fn()
resp = list_membership_plans(current_user=_make_cu())
plans = resp["plans"]
plan_ids = {p["plan_id"] for p in plans}
assert plan_ids == {"monthly", "quarterly", "yearly"}
for p in plans:
assert p["price_cents"] > 0
assert p["duration_days"] in (30, 90, 365)
assert 0 < p["points_discount"] <= 1.0
assert "max_resolution" in p["features"]
def test_longer_plans_cheaper_per_month(self):
import os # noqa: F401
list_membership_plans = self._import_plans_fn()
resp = list_membership_plans(current_user=_make_cu())
plans = resp["plans"]
monthly = next(p for p in plans if p["plan_id"] == "monthly")
quarterly = next(p for p in plans if p["plan_id"] == "quarterly")
yearly = next(p for p in plans if p["plan_id"] == "yearly")
assert monthly["monthly_price_cents"] == 1990
assert quarterly["monthly_price_cents"] < monthly["monthly_price_cents"]
assert yearly["monthly_price_cents"] < quarterly["monthly_price_cents"]
# ── P1-7: multiplier consistency ──────────────────────────────────────
class TestMultiplierConsistency:
def test_free_user_ai_title_costs_2(self):
"""ai_title base=1,免费用户 ceil(1*1.15)=2。"""
from packages.domain.points_rules import calculate_points_cost
assert calculate_points_cost("ai_title", is_member=False, quantity=1) == 2
def test_check_matches_direct_calculation(self):
"""check 端点 required_points 与 calculate_points_cost 结果一致。"""
from app.api.routes.points import check_points
from app.schemas.points import PointsCheckRequest
from packages.domain.points_rules import calculate_points_cost
svc = MagicMock()
svc.check_daily_free_clip.return_value = False
svc.get_or_create_account.return_value = {"balance": 999}
db = MagicMock()
cu = _make_cu()
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
body = PointsCheckRequest(scene_key=scene, quantity=1)
with patch("app.api.routes.points._get_service", return_value=svc):
resp = check_points(body=body, current_user=cu, db=db)
expected = calculate_points_cost(scene, is_member=False, quantity=1)
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"