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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
199 lines
8.0 KiB
Python
199 lines
8.0 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 返回 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}"
|