Files
xiaoxia-saas/tests/unit/test_points_routes.py
T
xiaoxia 13c302c037
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m24s
CI/CD Pipeline / Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m6s
CI/CD Pipeline / Validate - Style (push) Successful in 5m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Failing after 6h5m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 6h2m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 6h5m9s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 6h2m58s
CI/CD Pipeline / PR Build API Image (push) Failing after 6h4m9s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 6h2m58s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 6h4m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 6h4m10s
fix(#1895): points API 字段对齐(P0 修复 + P1 补全) (#1951)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-16 09:17:33 +08:00

199 lines
8.0 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
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:
def test_plans_endpoint_returns_three_tiers(self):
from app.api.routes.points import list_membership_plans
resp = list_membership_plans(_current_user=_make_cu())
plan_ids = {p.plan_id for p in resp.plans}
assert plan_ids == {"monthly", "quarterly", "yearly"}
for p in resp.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):
from app.api.routes.points import list_membership_plans
resp = list_membership_plans(_current_user=_make_cu())
monthly = next(p for p in resp.plans if p.plan_id == "monthly")
quarterly = next(p for p in resp.plans if p.plan_id == "quarterly")
yearly = next(p for p in resp.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}"