bd617128ce
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m15s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m45s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Style (push) Successful in 6m8s
CI/CD Pipeline / Integration Tests (push) Successful in 6m25s
AI Code Review / AI Code Review (pull_request) Successful in 7m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m50s
CI/CD Pipeline / Validate - Security (push) Successful in 13m4s
CI/CD Pipeline / Unit Tests (push) Successful in 15m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
240 lines
10 KiB
Python
240 lines
10 KiB
Python
"""积分/会员 API 路由对齐测试 — fix/1895-points-api-align
|
|
|
|
覆盖:
|
|
- P0-1: POST /points/recharge 返回 pay_params / points_amount / expire_at
|
|
- P0-2: POST /points/check 任意 scene_key 均可查询(已下线场景返回 cost=0,不报错)
|
|
- 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 任意 scene_key(已下线场景返回 cost=0) ──────────────────
|
|
|
|
|
|
class TestCheckPointsUnknownScene:
|
|
def test_unknown_scene_returns_zero_cost_not_error(self):
|
|
"""任意 scene_key 均可查询,已下线/未知场景返回 cost=0(免费放行)。"""
|
|
from app.api.routes.points import check_points
|
|
from app.schemas.points import PointsCheckRequest
|
|
|
|
svc = MagicMock()
|
|
svc.get_or_create_account.return_value = {"balance": 0}
|
|
db = MagicMock()
|
|
cu = _make_cu()
|
|
|
|
with (
|
|
patch("app.api.routes.points._credits_enabled", return_value=True),
|
|
patch("app.api.routes.points._get_service", return_value=svc),
|
|
):
|
|
for scene in ["ai_script", "ai_voice", "ai_video", "ai_title", "ai_cover", "nonexistent"]:
|
|
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
|
resp = check_points(body=body, current_user=cu, db=db)
|
|
assert resp.required_points == 0, f"{scene} should be free"
|
|
assert resp.allowed is True
|
|
|
|
def test_voice_clone_synth_still_charges(self):
|
|
"""合法付费场景 voice_clone_synth 正常计费:免费用户 1 分钟 = ceil(1*1.15)=2 积分。"""
|
|
from app.api.routes.points import check_points
|
|
from app.schemas.points import PointsCheckRequest
|
|
|
|
svc = MagicMock()
|
|
svc.get_or_create_account.return_value = {"balance": 50}
|
|
db = MagicMock()
|
|
cu = _make_cu()
|
|
body = PointsCheckRequest(scene_key="voice_clone_synth", quantity=1, duration_minutes=1)
|
|
|
|
with (
|
|
patch("app.api.routes.points._credits_enabled", return_value=True),
|
|
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())
|
|
# 场景列表包含 voice_clone_train / voice_clone_synth / viral_video(爆款视频为动态定价)
|
|
keys = {r.scene_key for r in resp.rules}
|
|
assert {"voice_clone_train", "voice_clone_synth", "viral_video"}.issubset(keys)
|
|
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_voice_clone_synth_1min_costs_2(self):
|
|
"""voice_clone_synth base=1,免费用户 ceil(1*1.15)=2。"""
|
|
from packages.domain.points_rules import calculate_points_cost
|
|
|
|
assert calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=1) == 2
|
|
|
|
def test_retired_scenes_return_zero(self):
|
|
"""已下线场景(ai_voice/ai_title/ai_cover/ai_rewrite 等)calculate_points_cost 统一返回 0。"""
|
|
from packages.domain.points_rules import calculate_points_cost
|
|
|
|
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite", "ai_video", "ai_digital_human"]:
|
|
assert calculate_points_cost(scene, is_member=False, quantity=1) == 0
|
|
|
|
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.get_or_create_account.return_value = {"balance": 999}
|
|
db = MagicMock()
|
|
cu = _make_cu()
|
|
|
|
with patch("app.api.routes.points._credits_enabled", return_value=True):
|
|
for scene in ["voice_clone_synth", "voice_clone_train", "ai_voice", "ai_video", "ai_title"]:
|
|
body = PointsCheckRequest(scene_key=scene, 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)
|
|
expected = calculate_points_cost(scene, is_member=False, quantity=1, duration_minutes=1)
|
|
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
|